diff --git a/app/src/androidTest/kotlin/be/scri/helpers/KeyboardTest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/helpers/KeyboardTest.kt similarity index 96% rename from app/src/androidTest/kotlin/be/scri/helpers/KeyboardTest.kt rename to app/src/androidTestKeyboards/kotlin/be/scri/helpers/KeyboardTest.kt index 74b082ec..8e88881c 100644 --- a/app/src/androidTest/kotlin/be/scri/helpers/KeyboardTest.kt +++ b/app/src/androidTestKeyboards/kotlin/be/scri/helpers/KeyboardTest.kt @@ -59,7 +59,7 @@ class KeyboardTest { } every { mockInputConnection.deleteSurroundingText(1, 0) } returns true - keyHandler.handleKey(KeyboardBase.KEYCODE_DELETE, "en") + keyHandler.handleKey(KeyboardBase.Companion.KEYCODE_DELETE, "en") assert(currentText.length == initialText.length - 1) { "Expected length ${initialText.length - 1}, but got ${currentText.length}" @@ -81,7 +81,7 @@ class KeyboardTest { mockInputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) } - keyHandler.handleKey(KeyboardBase.KEYCODE_ENTER, "en") + keyHandler.handleKey(KeyboardBase.Companion.KEYCODE_ENTER, "en") verify(exactly = 1) { mockIME.handleKeycodeEnter() } verify(exactly = 1) { diff --git a/app/src/androidTest/kotlin/be/scri/helpers/data/PluralFormsManagerTest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/helpers/data/PluralFormsManagerTest.kt similarity index 100% rename from app/src/androidTest/kotlin/be/scri/helpers/data/PluralFormsManagerTest.kt rename to app/src/androidTestKeyboards/kotlin/be/scri/helpers/data/PluralFormsManagerTest.kt diff --git a/app/src/androidTest/kotlin/be/scri/services/GeneralKeyboardIMETest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/services/GeneralKeyboardIMETest.kt similarity index 50% rename from app/src/androidTest/kotlin/be/scri/services/GeneralKeyboardIMETest.kt rename to app/src/androidTestKeyboards/kotlin/be/scri/services/GeneralKeyboardIMETest.kt index b6d755fb..b75741f1 100644 --- a/app/src/androidTest/kotlin/be/scri/services/GeneralKeyboardIMETest.kt +++ b/app/src/androidTestKeyboards/kotlin/be/scri/services/GeneralKeyboardIMETest.kt @@ -19,41 +19,47 @@ class GeneralKeyboardIMETest { @Before fun setUp() { - inputConnection = - mockk { - every { getTextBeforeCursor(any(), any()) } returns "" - } + inputConnection = mockk(relaxed = true) + + ime = mockk(relaxed = true) - ime = - mockk(relaxed = true) { - every { getCurrentInputConnection() } returns inputConnection + every { ime.currentInputConnection } returns inputConnection - every { hasTextBeforeCursor } answers { - val text = inputConnection.getTextBeforeCursor(Int.MAX_VALUE, 0)?.toString() ?: "" - text.isNotEmpty() && text.trim() != "." && text.trim() != "" - } + every { ime.hasTextBeforeCursor } answers { + val ic = ime.currentInputConnection + if (ic == null) { + false + } else { + val text = ic.getTextBeforeCursor(Int.MAX_VALUE, 0)?.toString() ?: "" + text.isNotEmpty() && text.trim() != "." && text.trim() != "" } + } } @Test fun hasTextBeforeCursor_returnsTrue_whenTextExists() { - every { inputConnection.getTextBeforeCursor(Int.MAX_VALUE, 0) } returns "hello" + every { inputConnection.getTextBeforeCursor(any(), any()) } returns "hello" - println("Result: ${ime.hasTextBeforeCursor}") assertTrue(ime.hasTextBeforeCursor) } @Test fun hasTextBeforeCursor_returnsFalse_whenTextIsEmpty() { - every { inputConnection.getTextBeforeCursor(Int.MAX_VALUE, 0) } returns "" + every { inputConnection.getTextBeforeCursor(any(), any()) } returns "" assertFalse(ime.hasTextBeforeCursor) } @Test fun hasTextBeforeCursor_returnsFalse_whenTextIsPeriod() { - every { inputConnection.getTextBeforeCursor(Int.MAX_VALUE, 0) } returns ". " + every { inputConnection.getTextBeforeCursor(any(), any()) } returns ". " + + assertFalse(ime.hasTextBeforeCursor) + } + @Test + fun hasTextBeforeCursor_returnsFalse_whenInputConnectionIsNull() { + every { ime.currentInputConnection } returns null assertFalse(ime.hasTextBeforeCursor) } } diff --git a/app/src/androidTest/kotlin/be/scri/ui/screens/about/AboutUtilInstrumentedTest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/about/AboutUtilInstrumentedTest.kt similarity index 96% rename from app/src/androidTest/kotlin/be/scri/ui/screens/about/AboutUtilInstrumentedTest.kt rename to app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/about/AboutUtilInstrumentedTest.kt index 79e60531..d86b336e 100644 --- a/app/src/androidTest/kotlin/be/scri/ui/screens/about/AboutUtilInstrumentedTest.kt +++ b/app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/about/AboutUtilInstrumentedTest.kt @@ -297,11 +297,11 @@ class AboutUtilInstrumentedTest { println("Testing ExternalLinks constants...") // Test that external links are properly defined. - assertThat(be.scri.ui.screens.about.ExternalLinks.GITHUB_SCRIBE).isNotEmpty() - assertThat(be.scri.ui.screens.about.ExternalLinks.GITHUB_ISSUES).isNotEmpty() - assertThat(be.scri.ui.screens.about.ExternalLinks.GITHUB_RELEASES).isNotEmpty() - assertThat(be.scri.ui.screens.about.ExternalLinks.MATRIX).isNotEmpty() - assertThat(be.scri.ui.screens.about.ExternalLinks.MASTODON).isNotEmpty() + assertThat(ExternalLinks.GITHUB_SCRIBE).isNotEmpty() + assertThat(ExternalLinks.GITHUB_ISSUES).isNotEmpty() + assertThat(ExternalLinks.GITHUB_RELEASES).isNotEmpty() + assertThat(ExternalLinks.MATRIX).isNotEmpty() + assertThat(ExternalLinks.MASTODON).isNotEmpty() println("ExternalLinks constants test passed!") } diff --git a/app/src/androidTest/kotlin/be/scri/ui/screens/settings/ScribeItemListTest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/ScribeItemListTest.kt similarity index 100% rename from app/src/androidTest/kotlin/be/scri/ui/screens/settings/ScribeItemListTest.kt rename to app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/ScribeItemListTest.kt diff --git a/app/src/androidTest/kotlin/be/scri/ui/screens/settings/SettingsScreenInstallKeyboardButtonTest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/SettingsScreenInstallKeyboardButtonTest.kt similarity index 100% rename from app/src/androidTest/kotlin/be/scri/ui/screens/settings/SettingsScreenInstallKeyboardButtonTest.kt rename to app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/SettingsScreenInstallKeyboardButtonTest.kt diff --git a/app/src/androidTest/kotlin/be/scri/ui/screens/settings/SettingsScreenInstrumentedTest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/SettingsScreenInstrumentedTest.kt similarity index 100% rename from app/src/androidTest/kotlin/be/scri/ui/screens/settings/SettingsScreenInstrumentedTest.kt rename to app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/SettingsScreenInstrumentedTest.kt diff --git a/app/src/androidTest/kotlin/be/scri/ui/screens/settings/SettingsUtilTest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/SettingsUtilTest.kt similarity index 100% rename from app/src/androidTest/kotlin/be/scri/ui/screens/settings/SettingsUtilTest.kt rename to app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/SettingsUtilTest.kt diff --git a/app/src/main/assets/i18n b/app/src/main/assets/i18n index 5dd446bb..ec4bc4ae 160000 --- a/app/src/main/assets/i18n +++ b/app/src/main/assets/i18n @@ -1 +1 @@ -Subproject commit 5dd446bb4cb8b199f41a95298164610b4143545a +Subproject commit ec4bc4ae90fb7d0e105b580f325bebfd8470ba2c diff --git a/app/src/main/java/be/scri/helpers/KeyHandler.kt b/app/src/main/java/be/scri/helpers/KeyHandler.kt index 0f0e148a..928e2979 100644 --- a/app/src/main/java/be/scri/helpers/KeyHandler.kt +++ b/app/src/main/java/be/scri/helpers/KeyHandler.kt @@ -201,12 +201,7 @@ class KeyHandler( */ private fun handleDeleteKey() { - val isCommandBarActive = - ime.currentState == ScribeState.TRANSLATE || - ime.currentState == ScribeState.CONJUGATE || - ime.currentState == ScribeState.PLURAL - - ime.handleDelete(isCommandBarActive, ime.isDeleteRepeating()) // Pass the actual repeating status + ime.handleDelete(ime.isDeleteRepeating()) // pass the actual repeating status if (ime.currentState == ScribeState.IDLE) { val currentWord = ime.getLastWordBeforeCursor() @@ -356,8 +351,8 @@ class KeyHandler( ScribeState.TRANSLATE, ScribeState.CONJUGATE, ScribeState.PLURAL, - -> true // Use command bar for actual commands - else -> false // Use main input field for IDLE and SELECT_COMMAND + -> true // use command bar for actual commands + else -> false // use main input field for IDLE and SELECT_COMMAND } ime.handleElseCondition(code, ime.keyboardMode, isCommandBarActive) diff --git a/app/src/main/java/be/scri/helpers/ui/KeyboardUIManager.kt b/app/src/main/java/be/scri/helpers/ui/KeyboardUIManager.kt new file mode 100644 index 00000000..cc95db4c --- /dev/null +++ b/app/src/main/java/be/scri/helpers/ui/KeyboardUIManager.kt @@ -0,0 +1,876 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Configuration +import android.graphics.Color +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.view.LayoutInflater +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.graphics.toColorInt +import be.scri.R +import be.scri.R.color.white +import be.scri.databinding.InputMethodViewBinding +import be.scri.helpers.KeyboardBase +import be.scri.helpers.LanguageMappingConstants.conjugatePlaceholder +import be.scri.helpers.LanguageMappingConstants.getLanguageAlias +import be.scri.helpers.LanguageMappingConstants.pluralPlaceholder +import be.scri.helpers.LanguageMappingConstants.translatePlaceholder +import be.scri.helpers.PreferencesHelper +import be.scri.helpers.PreferencesHelper.getIsDarkModeOrNot +import be.scri.helpers.english.ENInterfaceVariables.ALREADY_PLURAL_MSG +import be.scri.services.GeneralKeyboardIME +import be.scri.services.GeneralKeyboardIME.ScribeState +import be.scri.views.KeyboardView + +/** + * Manages the UI elements and state transitions for the GeneralKeyboardIME. + * This class handles View interactions, visibility toggling, and layout updates. + */ +@Suppress("TooManyFunctions", "LargeClass") +class KeyboardUIManager( + val binding: InputMethodViewBinding, + private val context: Context, + private val listener: KeyboardUIListener, +) { + interface KeyboardUIListener { + fun onScribeKeyOptionsClicked() + + fun onScribeKeyToolbarClicked() + + fun onTranslateClicked() + + fun onConjugateClicked() + + fun onPluralClicked() + + fun onCloseClicked() + + fun onEmojiSelected(emoji: String) + + fun onSuggestionClicked(suggestion: String) + + fun getKeyboardLayoutXML(): Int + + fun getCurrentEnterKeyType(): Int + + fun commitText(text: String) + + fun onKeyboardActionListener(): KeyboardView.OnKeyboardActionListener + + fun processLinguisticSuggestions(word: String) + } + + var keyboardView: KeyboardView = binding.keyboardView + var keyboard: KeyboardBase? = null + + // UI Elements + var pluralBtn: Button? = binding.pluralBtn + var emojiBtnPhone1: Button? = binding.emojiBtnPhone1 + var emojiSpacePhone: View? = binding.emojiSpacePhone + var emojiBtnPhone2: Button? = binding.emojiBtnPhone2 + var emojiBtnTablet1: Button? = binding.emojiBtnTablet1 + var emojiSpaceTablet1: View? = binding.emojiSpaceTablet1 + var emojiBtnTablet2: Button? = binding.emojiBtnTablet2 + var emojiSpaceTablet2: View? = binding.emojiSpaceTablet2 + var emojiBtnTablet3: Button? = binding.emojiBtnTablet3 + var genderSuggestionLeft: Button? = binding.translateBtnLeft + var genderSuggestionRight: Button? = binding.translateBtnRight + + // State variables specific to UI rendering. + var currentCommandBarHint: String = "" + var commandBarHintColor: Int = Color.GRAY + var commandBarTextColor: Int = Color.BLACK + private var earlierValue: Int? = keyboardView.setEnterKeyIcon(ScribeState.IDLE) + + private var currentPage = 0 + private val totalPages = 3 + private val explanationStrings = + arrayOf( + R.string.i18n_app_keyboard_not_in_wikidata_explanation_1, + R.string.i18n_app_keyboard_not_in_wikidata_explanation_2, + R.string.i18n_app_keyboard_not_in_wikidata_explanation_3, + ) + + init { + setupClickListeners() + } + + private fun setupClickListeners() { + binding.scribeKeyOptions.setOnClickListener { listener.onScribeKeyOptionsClicked() } + binding.scribeKeyToolbar.setOnClickListener { listener.onScribeKeyToolbarClicked() } + + binding.translateBtn.setOnClickListener { listener.onTranslateClicked() } + binding.conjugateBtn.setOnClickListener { listener.onConjugateClicked() } + binding.pluralBtn.setOnClickListener { listener.onPluralClicked() } + + binding.scribeKeyClose.setOnClickListener { listener.onCloseClicked() } + + // Info button listener for INVALID state. + binding.ivInfo.setOnClickListener { showInvalidInfo() } + } + + /** + * Updates the color of the Enter key based on the current Scribe state and theme (dark/light mode). + * + * @param isDarkMode The current dark mode status. If null, it will be determined from context. + * @param currentState The current state of the keyboard. + */ + fun updateEnterKeyColor( + isDarkMode: Boolean?, + currentState: ScribeState, + ) { + val resolvedIsDarkMode = isDarkMode ?: getIsDarkModeOrNot(context) + when (currentState) { + ScribeState.IDLE, ScribeState.SELECT_COMMAND -> { + keyboardView.setEnterKeyIcon(ScribeState.IDLE, earlierValue) + keyboardView.setEnterKeyColor(null, isDarkMode = resolvedIsDarkMode) + } + else -> { + keyboardView.setEnterKeyColor(context.getColor(R.color.color_primary)) + keyboardView.setEnterKeyIcon(ScribeState.PLURAL, earlierValue) + } + } + val scribeKeyTint = if (resolvedIsDarkMode) R.color.light_key_color else R.color.light_key_text_color + binding.scribeKeyOptions.foregroundTintList = ContextCompat.getColorStateList(context, scribeKeyTint) + binding.scribeKeyToolbar.foregroundTintList = ContextCompat.getColorStateList(context, scribeKeyTint) + } + + /** + * The main dispatcher for updating the entire keyboard UI. It calls the appropriate setup function + * based on the current [ScribeState]. + */ + fun updateUI( + currentState: ScribeState, + language: String, + emojiAutoSuggestionEnabled: Boolean, + autoSuggestEmojis: MutableList?, + conjugateOutput: Map>>?, + conjugateLabels: Set?, + selectedConjugationSubCategory: String?, + currentVerbForConjugation: String?, + ) { + val isUserDarkMode = getIsDarkModeOrNot(context) + + when (currentState) { + ScribeState.IDLE -> setupIdleView(language, emojiAutoSuggestionEnabled, autoSuggestEmojis) + ScribeState.SELECT_COMMAND -> setupSelectCommandView(language) + ScribeState.INVALID -> setupInvalidView(language) + ScribeState.TRANSLATE -> { + setupToolbarView(currentState, language, conjugateOutput, conjugateLabels, selectedConjugationSubCategory, currentVerbForConjugation) + binding.translateBtn.text = translatePlaceholder[getLanguageAlias(language)] ?: "Translate" + binding.translateBtn.visibility = View.VISIBLE + } + ScribeState.CONJUGATE, ScribeState.SELECT_VERB_CONJUNCTION, ScribeState.PLURAL -> { + setupToolbarView(currentState, language, conjugateOutput, conjugateLabels, selectedConjugationSubCategory, currentVerbForConjugation) + } + ScribeState.ALREADY_PLURAL -> setupAlreadyPluralView() + } + + updateEnterKeyColor(isUserDarkMode, currentState) + } + + /** + * Configures the UI for the `IDLE` state, showing default suggestions or emoji suggestions. + */ + private fun setupIdleView( + language: String, + emojiAutoSuggestionEnabled: Boolean, + autoSuggestEmojis: MutableList?, + ) { + binding.commandOptionsBar.visibility = View.VISIBLE + binding.toolbarBar.visibility = View.GONE + + val isUserDarkMode = getIsDarkModeOrNot(context) + + binding.commandOptionsBar.setBackgroundColor( + ContextCompat.getColor( + context, + if (isUserDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color, + ), + ) + + val textColor = if (isUserDarkMode) Color.WHITE else "#1E1E1E".toColorInt() + + listOf(binding.translateBtn, binding.conjugateBtn, binding.pluralBtn).forEachIndexed { index, button -> + button.visibility = View.VISIBLE + button.background = null + button.setTextColor(textColor) + button.text = HintUtils.getBaseAutoSuggestions(language).getOrNull(index) + button.isAllCaps = false + button.textSize = GeneralKeyboardIME.SUGGESTION_SIZE + button.setOnClickListener(null) + } + + listOf(binding.separator2, binding.separator3).forEach { separator -> + separator.setBackgroundColor(ContextCompat.getColor(context, R.color.special_key_light)) + val params = separator.layoutParams + // Convert 0.5dp to pixels. coerceAtLeast(1) ensures it's never zero. + params.width = (0.5f * context.resources.displayMetrics.density).toInt().coerceAtLeast(1) + separator.layoutParams = params + separator.visibility = View.VISIBLE + } + + binding.separator1.visibility = View.GONE + binding.ivInfo.visibility = View.GONE + binding.conjugateGridContainer.visibility = View.GONE + binding.keyboardView.visibility = View.VISIBLE + + binding.scribeKeyOptions.foreground = AppCompatResources.getDrawable(context, R.drawable.ic_scribe_icon_vector) + + initializeKeyboard(listener.getKeyboardLayoutXML()) + + updateButtonVisibility(ScribeState.IDLE, emojiAutoSuggestionEnabled, autoSuggestEmojis) + updateEmojiSuggestion(ScribeState.IDLE, emojiAutoSuggestionEnabled, autoSuggestEmojis) + binding.commandBar.setText("") + disableAutoSuggest(language) + } + + /** + * Configures the UI for the `SELECT_COMMAND` state, showing the main command buttons + * (Translate, Conjugate, Plural). + */ + private fun setupSelectCommandView(language: String) { + binding.commandOptionsBar.visibility = View.VISIBLE + binding.toolbarBar.visibility = View.GONE + + val isUserDarkMode = getIsDarkModeOrNot(context) + binding.commandOptionsBar.setBackgroundColor( + ContextCompat.getColor( + context, + if (isUserDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color, + ), + ) + + val langAlias = getLanguageAlias(language) + + updateButtonVisibility(ScribeState.SELECT_COMMAND, false, null) + + binding.translateBtn.setOnClickListener { listener.onTranslateClicked() } + binding.conjugateBtn.setOnClickListener { listener.onConjugateClicked() } + binding.pluralBtn.setOnClickListener { listener.onPluralClicked() } + + val buttonTextColor = if (isUserDarkMode) Color.WHITE else Color.BLACK + + listOf(binding.translateBtn, binding.conjugateBtn, binding.pluralBtn).forEach { button -> + button.visibility = View.VISIBLE + button.background = ContextCompat.getDrawable(context, R.drawable.button_background_rounded) + button.backgroundTintList = ContextCompat.getColorStateList(context, R.color.theme_scribe_blue) + button.setTextColor(buttonTextColor) + button.textSize = GeneralKeyboardIME.SUGGESTION_SIZE + } + + binding.translateBtn.text = translatePlaceholder[langAlias] ?: "Translate" + binding.conjugateBtn.text = conjugatePlaceholder[langAlias] ?: "Conjugate" + binding.pluralBtn.text = pluralPlaceholder[langAlias] ?: "Plural" + + val separatorColor = (if (isUserDarkMode) GeneralKeyboardIME.DARK_THEME else GeneralKeyboardIME.LIGHT_THEME).toColorInt() + binding.separator2.setBackgroundColor(separatorColor) + binding.separator3.setBackgroundColor(separatorColor) + + val spaceInDp = 4 + val spaceInPx = (spaceInDp * context.resources.displayMetrics.density).toInt() + listOf(binding.separator2, binding.separator3).forEach { separator -> + separator.setBackgroundColor(Color.TRANSPARENT) + val params = separator.layoutParams + params.width = spaceInPx + separator.layoutParams = params + } + + binding.separator1.visibility = View.GONE + binding.separator2.visibility = View.VISIBLE + binding.separator3.visibility = View.VISIBLE + binding.separator4.visibility = View.GONE + binding.separator5.visibility = View.GONE + binding.separator6.visibility = View.GONE + binding.ivInfo.visibility = View.GONE + binding.scribeKeyOptions.foreground = AppCompatResources.getDrawable(context, R.drawable.close) + } + + /** + * Configures the UI for command modes (`TRANSLATE`, `CONJUGATE`, etc.), showing the command bar and toolbar. + */ + @SuppressLint("InflateParams") + private fun setupToolbarView( + currentState: ScribeState, + language: String, + conjugateOutput: Map>>?, + conjugateLabels: Set?, + selectedConjugationSubCategory: String?, + currentVerbForConjugation: String?, + ) { + binding.commandOptionsBar.visibility = View.GONE + binding.toolbarBar.visibility = View.VISIBLE + val isDarkMode = getIsDarkModeOrNot(context) + binding.toolbarBar.setBackgroundColor( + ContextCompat.getColor( + context, + if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color, + ), + ) + binding.ivInfo.visibility = View.GONE + + binding.scribeKeyToolbar.foreground = AppCompatResources.getDrawable(context, R.drawable.close) + + var hintWord: String? = null + var promptText: String? = null + + if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { + binding.conjugateGridContainer.visibility = View.VISIBLE + binding.keyboardView.visibility = View.GONE + + val grid = binding.conjugateGrid + grid.removeAllViews() + + val conjugateIndex = getValidatedConjugateIndex(conjugateOutput) + val title = conjugateOutput?.keys?.elementAtOrNull(conjugateIndex) + val languageOutput = title?.let { conjugateOutput[it] } + + val isSubSelection = selectedConjugationSubCategory != null + val showCategories = !isSubSelection && (languageOutput?.containsKey(title) != true) + + val forms = + if (isSubSelection) { + languageOutput?.get(selectedConjugationSubCategory)?.toList() ?: listOf("", "", "", "") + } else if (showCategories) { + languageOutput?.map { (_, values) -> + if (values.size == 1) values.first() else values.joinToString(" / ") + } ?: listOf("", "", "", "") + } else { + languageOutput?.get(title)?.toList() ?: listOf("", "", "", "") + } + + val layoutResId = + when { + isSubSelection -> R.layout.conjugate_grid_2x1 + language == "English" && forms.size <= 4 -> R.layout.conjugate_grid_2x2 + language in listOf("Russian", "Swedish") && forms.size <= 4 -> R.layout.conjugate_grid_2x2 + forms.size > 4 -> R.layout.conjugate_grid_3x2 + else -> R.layout.conjugate_grid_2x2 + } + + val layoutInflater = LayoutInflater.from(context) + val gridContent = layoutInflater.inflate(layoutResId, grid, false) as LinearLayout + grid.addView(gridContent) + + val buttonIds = + listOf( + R.id.conjugate_btn_1, + R.id.conjugate_btn_2, + R.id.conjugate_btn_3, + R.id.conjugate_btn_4, + R.id.conjugate_btn_5, + R.id.conjugate_btn_6, + ) + + buttonIds.forEachIndexed { i, btnId -> + val btn = gridContent.findViewById(btnId) + if (btn != null) { + btn.text = forms.getOrNull(i) ?: "" + btn.setOnClickListener { + val label = btn.text.toString() + if (label.isNotEmpty()) { + var handledAsCategory = false + if (showCategories) { + val matchingEntry = + languageOutput?.entries?.find { (_, values) -> + if (values.size == 1) values.first() == label else values.joinToString(" / ") == label + } + + if (matchingEntry != null) { + val (key, values) = matchingEntry + if (values.size > 1) { + // Category logic is handled in IME's commitText. + } + } + } + + if (!handledAsCategory) { + listener.commitText("$label ") + listener.processLinguisticSuggestions(label) + } + } + } + } + } + + setupConjugateArrows(gridContent, context) + + promptText = if (isSubSelection) selectedConjugationSubCategory else (title ?: "___") + hintWord = conjugateLabels?.lastOrNull() + } else { + binding.conjugateGridContainer.visibility = View.GONE + binding.keyboardView.visibility = View.VISIBLE + } + + updateCommandBarHintAndPrompt(currentState, language, promptText, isDarkMode, hintWord, currentVerbForConjugation) + } + + /** + * Sets up the navigation arrow buttons for the conjugation grid view. + */ + private fun setupConjugateArrows( + gridContent: View, + context: Context, + ) { + val arrowButtonIds = + listOf( + "conjugate_arrow_left_1", + "conjugate_arrow_right_1", + "conjugate_arrow_left_2", + "conjugate_arrow_right_2", + "conjugate_arrow_left_3", + "conjugate_arrow_right_3", + "conjugate_arrow_left", + "conjugate_arrow_right", + ) + + arrowButtonIds.forEach { arrowBtnName -> + val arrowBtnId = context.resources.getIdentifier(arrowBtnName, "id", context.packageName) + if (arrowBtnId != 0) { + val arrowBtn = gridContent.findViewById(arrowBtnId) + arrowBtn?.setOnClickListener { + val isLeft = arrowBtnName.contains("left") + val prefs = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + val current = prefs.getInt("conjugate_index", 0) + val newValue = if (isLeft) current - 1 else current + 1 + prefs.edit { putInt("conjugate_index", newValue) } + + listener.onConjugateClicked() + } + } + } + } + + /** + * Configures the UI for the `INVALID` state, which is shown when a command (e.g., translation) fails. + */ + @SuppressLint("SetTextI18n") + private fun setupInvalidView(language: String) { + binding.commandOptionsBar.visibility = View.GONE + binding.toolbarBar.visibility = View.VISIBLE + // Original logic: Invalid state actually uses the toolbarBar layout initially. + binding.invalidInfoBar.visibility = View.GONE + + val isDarkMode = getIsDarkModeOrNot(context) + + // Restore original logic: Set background on toolbarBar, not invalidInfoBar. + binding.toolbarBar.setBackgroundColor( + if (isDarkMode) "#1E1E1E".toColorInt() else "#d2d4da".toColorInt(), + ) + + binding.ivInfo.visibility = View.VISIBLE + binding.promptText.text = HintUtils.getInvalidHint(language = language) + ": " + binding.commandBar.hint = "" + binding.scribeKeyToolbar.foreground = AppCompatResources.getDrawable(context, R.drawable.ic_scribe_icon_vector) + } + + /** + * Configures the UI for the `ALREADY_PLURAL` state, which is shown when the user + * attempts to pluralize a word that is already plural. + */ + @SuppressLint("SetTextI18n") + private fun setupAlreadyPluralView() { + binding.commandOptionsBar.visibility = View.GONE + binding.toolbarBar.visibility = View.VISIBLE + val isDarkMode = getIsDarkModeOrNot(context) + binding.toolbarBar.setBackgroundColor(if (isDarkMode) "#1E1E1E".toColorInt() else "#d2d4da".toColorInt()) + binding.ivInfo.visibility = View.VISIBLE + binding.promptText.text = "$ALREADY_PLURAL_MSG: " + binding.commandBar.hint = "" + binding.scribeKeyToolbar.foreground = AppCompatResources.getDrawable(context, R.drawable.ic_scribe_icon_vector) + } + + /** + * Updates the hint and prompt text displayed in the command bar area based on the current state. + * + * @param currentState The current keyboard state. + * @param language The current language. + * @param text Specific text for the prompt (optional). + * @param isUserDarkMode The current dark mode status. + * @param word A word to include in the hint (optional). + */ + @SuppressLint("SetTextI18n") + fun updateCommandBarHintAndPrompt( + currentState: ScribeState, + language: String, + text: String? = null, + isUserDarkMode: Boolean? = null, + word: String? = null, + currentVerbForConjugation: String? = null, + ) { + val resolvedIsDarkMode = isUserDarkMode ?: getIsDarkModeOrNot(context) + val commandBarEditText = binding.commandBar + val promptTextView = binding.promptText + + commandBarHintColor = if (resolvedIsDarkMode) context.getColor(R.color.hint_white) else context.getColor(R.color.hint_black) + commandBarTextColor = if (resolvedIsDarkMode) context.getColor(white) else Color.BLACK + val backgroundColor = if (resolvedIsDarkMode) R.color.command_bar_color_dark else white + binding.commandBarLayout.backgroundTintList = ContextCompat.getColorStateList(context, backgroundColor) + + val promptTextStr = HintUtils.getPromptText(currentState, language, context, text) + promptTextView.text = promptTextStr + promptTextView.setTextColor(commandBarTextColor) + promptTextView.setBackgroundColor(context.getColor(backgroundColor)) + + if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { + val verbInfinitive = currentVerbForConjugation ?: "" + commandBarEditText.setText(": $verbInfinitive") + commandBarEditText.setTextColor(commandBarTextColor) + commandBarEditText.isFocusable = false + commandBarEditText.isFocusableInTouchMode = false + } else { + currentCommandBarHint = HintUtils.getCommandBarHint(currentState, language, word) + commandBarEditText.isFocusable = true + commandBarEditText.isFocusableInTouchMode = true + commandBarEditText.setTextColor(commandBarHintColor) + setCommandBarTextWithCursor(currentCommandBarHint, cursorAtStart = true) + commandBarEditText.requestFocus() + } + } + + /** + * Initializes or re-initializes the keyboard with a new layout. + * + * @param xmlId The resource ID of the keyboard layout XML. + */ + fun initializeKeyboard(xmlId: Int) { + val enterKeyType = listener.getCurrentEnterKeyType() + keyboard = KeyboardBase(context, xmlId, enterKeyType) + keyboardView.setKeyboard(keyboard!!) + keyboardView.mOnKeyboardActionListener = listener.onKeyboardActionListener() + keyboardView.requestLayout() + } + + /** + * Sets up the currency symbol on the keyboard based on user preferences. + * + * @param language The current language. + */ + fun setupCurrencySymbol(language: String) { + val currencySymbol = PreferencesHelper.getDefaultCurrencySymbol(context, language) + keyboardView.setKeyLabel(currencySymbol, "", KeyboardBase.CODE_CURRENCY) + } + + /** + * Retrieves and validates the stored index for the current conjugation view. + * Ensures the index is within the bounds of available conjugation types. + */ + private fun getValidatedConjugateIndex(conjugateOutput: Map?): Int { + val prefs = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + var index = prefs.getInt("conjugate_index", 0) + val maxIndex = conjugateOutput?.keys?.count()?.minus(1) ?: -1 + index = if (maxIndex >= 0) index.coerceIn(0, maxIndex) else 0 + prefs.edit { putInt("conjugate_index", index) } + return index + } + + // MARK: Suggestion and Visibility + + /** + * Updates the visibility of the suggestion buttons based on device type (phone/tablet) + * and whether auto-suggestions are currently active. + */ + fun updateButtonVisibility( + currentState: ScribeState, + isAutoSuggestEnabled: Boolean, + autoSuggestEmojis: MutableList?, + ) { + if (currentState != ScribeState.IDLE) { + setupDefaultButtonVisibility() + return + } + + val isTablet = + (context.resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK) >= + Configuration.SCREENLAYOUT_SIZE_LARGE + + val emojiCount = if (isAutoSuggestEnabled) autoSuggestEmojis?.size ?: 0 else 0 + + if (isTablet) updateTabletButtonVisibility(emojiCount) else updatePhoneButtonVisibility(emojiCount) + } + + /** + * Sets the default visibility for buttons when not in the `IDLE` state. + * Hides all suggestion-related buttons. + */ + private fun setupDefaultButtonVisibility() { + pluralBtn?.visibility = View.VISIBLE + emojiBtnPhone1?.visibility = View.GONE + emojiBtnPhone2?.visibility = View.GONE + emojiBtnTablet1?.visibility = View.GONE + emojiBtnTablet2?.visibility = View.GONE + emojiBtnTablet3?.visibility = View.GONE + binding.separator4.visibility = View.GONE + binding.separator5.visibility = View.GONE + binding.separator6.visibility = View.GONE + } + + /** + * Handles the logic for showing/hiding suggestion buttons specifically on tablet layouts. + * + * @param emojiCount The number of available emoji suggestions. + */ + private fun updateTabletButtonVisibility(emojiCount: Int) { + pluralBtn?.visibility = if (emojiCount > 0) View.INVISIBLE else View.VISIBLE + + when (emojiCount) { + 0 -> { + emojiBtnTablet1?.visibility = View.GONE + emojiSpaceTablet1?.visibility = View.GONE + emojiBtnTablet2?.visibility = View.GONE + emojiSpaceTablet2?.visibility = View.GONE + emojiBtnTablet3?.visibility = View.GONE + } + 1 -> { + emojiBtnTablet1?.visibility = View.VISIBLE + emojiSpaceTablet1?.visibility = View.GONE + emojiBtnTablet2?.visibility = View.GONE + emojiSpaceTablet2?.visibility = View.GONE + emojiBtnTablet3?.visibility = View.GONE + } + 2 -> { + emojiBtnTablet1?.visibility = View.VISIBLE + emojiSpaceTablet1?.visibility = View.VISIBLE + emojiBtnTablet2?.visibility = View.VISIBLE + emojiSpaceTablet2?.visibility = View.GONE + emojiBtnTablet3?.visibility = View.GONE + } + else -> { + emojiBtnTablet1?.visibility = View.VISIBLE + emojiSpaceTablet1?.visibility = View.VISIBLE + emojiBtnTablet2?.visibility = View.VISIBLE + emojiSpaceTablet2?.visibility = View.VISIBLE + emojiBtnTablet3?.visibility = View.VISIBLE + } + } + + binding.separator5.visibility = View.GONE + binding.separator6.visibility = View.GONE + emojiBtnPhone1?.visibility = View.GONE + emojiSpacePhone?.visibility = View.GONE + emojiBtnPhone2?.visibility = View.GONE + binding.separator4.visibility = View.GONE + } + + /** + * Handles the logic for showing/hiding suggestion buttons specifically on phone layouts. + * + * @param emojiCount The number of available emoji suggestions. + */ + private fun updatePhoneButtonVisibility(emojiCount: Int) { + pluralBtn?.visibility = if (emojiCount > 0) View.INVISIBLE else View.VISIBLE + + when { + emojiCount == 1 -> { + emojiBtnPhone1?.visibility = View.VISIBLE + emojiSpacePhone?.visibility = View.GONE + emojiBtnPhone2?.visibility = View.GONE + } + emojiCount >= 2 -> { + emojiBtnPhone1?.visibility = View.VISIBLE + emojiSpacePhone?.visibility = View.VISIBLE + emojiBtnPhone2?.visibility = View.VISIBLE + } + else -> { + emojiBtnPhone1?.visibility = View.GONE + emojiSpacePhone?.visibility = View.GONE + emojiBtnPhone2?.visibility = View.GONE + } + } + + binding.separator4.visibility = if (emojiCount > 1) View.VISIBLE else View.GONE + + emojiBtnTablet1?.visibility = View.GONE + emojiSpaceTablet1?.visibility = View.GONE + emojiBtnTablet2?.visibility = View.GONE + emojiSpaceTablet2?.visibility = View.GONE + emojiBtnTablet3?.visibility = View.GONE + binding.separator5.visibility = View.GONE + binding.separator6.visibility = View.GONE + } + + /** + * Updates the text of the suggestion buttons, primarily for displaying emoji suggestions. + * + * @param currentState The current state of the keyboard. + * @param isAutoSuggestEnabled true if suggestions are active. + * @param autoSuggestEmojis The list of emojis to display. + */ + fun updateEmojiSuggestion( + currentState: ScribeState, + isAutoSuggestEnabled: Boolean, + autoSuggestEmojis: MutableList?, + ) { + if (currentState != ScribeState.IDLE) return + + val tabletButtons = listOf(binding.emojiBtnTablet1, binding.emojiBtnTablet2, binding.emojiBtnTablet3) + val phoneButtons = listOf(binding.emojiBtnPhone1, binding.emojiBtnPhone2) + + if (isAutoSuggestEnabled && autoSuggestEmojis != null) { + val emojiListener = { emoji: String -> + View.OnClickListener { listener.onEmojiSelected(emoji) } + } + + tabletButtons.forEachIndexed { index, button -> + val emoji = autoSuggestEmojis.getOrNull(index) ?: "" + button.text = emoji + button.setOnClickListener(if (emoji.isNotEmpty()) emojiListener(emoji) else null) + } + + phoneButtons.forEachIndexed { index, button -> + val emoji = autoSuggestEmojis.getOrNull(index) ?: "" + button.text = emoji + button.setOnClickListener(if (emoji.isNotEmpty()) emojiListener(emoji) else null) + } + } else { + (tabletButtons + phoneButtons).forEach { button -> + button.text = "" + button.setOnClickListener(null) + } + } + } + + /** + * Disables all auto-suggestions and resets the suggestion buttons to their default, inactive state. + */ + fun disableAutoSuggest(language: String) { + binding.translateBtnRight.visibility = View.INVISIBLE + binding.translateBtnLeft.visibility = View.INVISIBLE + binding.translateBtn.visibility = View.VISIBLE + + val createSuggestionClickListener = { suggestion: String -> + View.OnClickListener { listener.onSuggestionClicked(suggestion) } + } + + val suggestions = HintUtils.getBaseAutoSuggestions(language) + + val suggestion1 = suggestions.getOrNull(0) ?: "" + binding.translateBtn.text = suggestion1 + binding.translateBtn.background = null + binding.translateBtn.setOnClickListener(createSuggestionClickListener(suggestion1)) + + val suggestion2 = suggestions.getOrNull(1) ?: "" + binding.conjugateBtn.text = suggestion2 + binding.conjugateBtn.setOnClickListener(createSuggestionClickListener(suggestion2)) + + val suggestion3 = suggestions.getOrNull(2) ?: "" + binding.pluralBtn.text = suggestion3 + binding.pluralBtn.setOnClickListener(createSuggestionClickListener(suggestion3)) + + handleTextSizeForSuggestion(binding.translateBtn) + } + + /** + * Sets the text size and color for a default, non-active suggestion button. + * + * @param button The button to style. + */ + private fun handleTextSizeForSuggestion(button: Button) { + button.textSize = GeneralKeyboardIME.SUGGESTION_SIZE + val isUserDarkMode = getIsDarkModeOrNot(context) + val colorRes = if (isUserDarkMode) R.color.white else android.R.color.black + button.setTextColor(ContextCompat.getColor(context, colorRes)) + } + + /** + * Sets the command bar text and ensures it ends with the custom cursor. + * + * @param text The text to set (without cursor). + * @param cursorAtStart The flag to check if the text in the EditText is empty to determine the position of the cursor. + */ + internal fun setCommandBarTextWithCursor( + text: String, + cursorAtStart: Boolean = false, + ) { + if (cursorAtStart) { + val hintWithCursor = GeneralKeyboardIME.CUSTOM_CURSOR + text + val spannable = SpannableString(hintWithCursor) + spannable.setSpan( + ForegroundColorSpan(commandBarTextColor), + 0, + 1, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + binding.commandBar.setText(spannable, TextView.BufferType.SPANNABLE) + } else { + val textWithCursor = text + GeneralKeyboardIME.CUSTOM_CURSOR + binding.commandBar.setText(textWithCursor) + } + binding.commandBar.setSelection(binding.commandBar.text.length) + } + + /** + * Gets the current text in the command bar without the cursor. + * + * @return The text content without the trailing cursor character. + */ + internal fun getCommandBarTextWithoutCursor(): String { + val currentText = binding.commandBar.text.toString() + return when { + currentText.startsWith(GeneralKeyboardIME.CUSTOM_CURSOR) -> currentText.drop(1) + currentText.endsWith(GeneralKeyboardIME.CUSTOM_CURSOR) -> currentText.dropLast(1) + else -> currentText + } + } + + /** + * Show information about Wikidata when the user clicks the information icon. + */ + private fun showInvalidInfo() { + binding.ivInfo.isClickable = true + binding.ivInfo.isFocusable = true + keyboardView.visibility = View.GONE + binding.invalidInfoBar.visibility = View.VISIBLE + setupWikidataButtons() + updateWikidataPage() + } + + private fun setupWikidataButtons() { + binding.buttonLeft.setOnClickListener { + if (currentPage > 0) { + currentPage-- + updateWikidataPage() + } + } + binding.buttonRight.setOnClickListener { + if (currentPage < totalPages - 1) { + currentPage++ + updateWikidataPage() + } + } + } + + /** + * Update Wikidata information based on current navigation state. + */ + private fun updateWikidataPage() { + binding.middleTextview.setText(explanationStrings[currentPage]) + updateDotIndicators() + } + + /** + * Update page indicators to show which Wikidata explanation the user is currently viewing. + */ + private fun updateDotIndicators() { + val pageIndicators = binding.pageIndicators + for (i in 0 until pageIndicators.childCount) { + val dot = pageIndicators.getChildAt(i) + dot.background = + ContextCompat.getDrawable( + context, + if (i == currentPage) R.drawable.dot_active else R.drawable.dot_inactive, + ) + } + } +} diff --git a/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt b/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt index b77affc4..21214568 100644 --- a/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt @@ -3,10 +3,8 @@ package be.scri.services import DataContract -import android.annotation.SuppressLint +import android.R.color.white import android.content.Context -import android.content.res.Configuration -import android.content.res.Resources import android.database.sqlite.SQLiteException import android.graphics.Color import android.graphics.drawable.GradientDrawable @@ -18,11 +16,7 @@ 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.text.Spannable -import android.text.SpannableString -import android.text.style.ForegroundColorSpan import android.util.Log -import android.view.InflateException import android.view.KeyEvent import android.view.View import android.view.inputmethod.EditorInfo @@ -32,15 +26,10 @@ 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.LinearLayout -import android.widget.TextView -import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.graphics.toColorInt import be.scri.R -import be.scri.R.color.md_grey_black_dark -import be.scri.R.color.white import be.scri.databinding.InputMethodViewBinding import be.scri.helpers.AnnotationTextUtils.handleColorAndTextForNounType import be.scri.helpers.AnnotationTextUtils.handleTextForCaseAnnotation @@ -49,10 +38,7 @@ import be.scri.helpers.BackspaceHandler import be.scri.helpers.DatabaseManagers import be.scri.helpers.EmojiUtils.insertEmoji import be.scri.helpers.KeyboardBase -import be.scri.helpers.LanguageMappingConstants.conjugatePlaceholder import be.scri.helpers.LanguageMappingConstants.getLanguageAlias -import be.scri.helpers.LanguageMappingConstants.pluralPlaceholder -import be.scri.helpers.LanguageMappingConstants.translatePlaceholder import be.scri.helpers.PreferencesHelper import be.scri.helpers.PreferencesHelper.getHoldKeyStyle import be.scri.helpers.PreferencesHelper.getIsDarkModeOrNot @@ -66,7 +52,7 @@ import be.scri.helpers.SHIFT_ON_PERMANENT import be.scri.helpers.SuggestionHandler import be.scri.helpers.data.AutocompletionDataManager import be.scri.helpers.english.ENInterfaceVariables.ALREADY_PLURAL_MSG -import be.scri.helpers.ui.HintUtils +import be.scri.helpers.ui.KeyboardUIManager import be.scri.views.KeyboardView import java.util.Locale @@ -77,8 +63,10 @@ private const val DATA_CONSTANT_3 = 3 abstract class GeneralKeyboardIME( var language: String, ) : InputMethodService(), - KeyboardView.OnKeyboardActionListener { - abstract fun getKeyboardLayoutXML(): Int + KeyboardView.OnKeyboardActionListener, + KeyboardUIManager.KeyboardUIListener { + // Abstract members required by subclasses (like EnglishKeyboardIME) + abstract override fun getKeyboardLayoutXML(): Int abstract val keyboardLetters: Int abstract val keyboardSymbols: Int @@ -86,29 +74,38 @@ abstract class GeneralKeyboardIME( open var keyboard: KeyboardBase? = null var keyboardView: KeyboardView? = null + + // UI Manager instance. + lateinit var uiManager: KeyboardUIManager + abstract var lastShiftPressTS: Long abstract var keyboardMode: Int abstract var inputTypeClass: Int abstract var enterKeyType: Int abstract var switchToLetters: Boolean - abstract var hasTextBeforeCursor: Boolean - // Delegate backspace handling to a separate class + /** + * Property used by EnglishKeyboardIME override. + * We define a custom getter here for the base logic, but subclasses can override the field. + */ + open var hasTextBeforeCursor: Boolean = false + get() { + val ic = currentInputConnection ?: return false + val text = ic.getTextBeforeCursor(Int.MAX_VALUE, 0)?.trim() ?: "" + return text.isNotEmpty() && text.lastOrNull() != '.' + } + set(value) { + field = value + } + + // Delegate backspace handling to a separate class. private val backspaceHandler = BackspaceHandler(this) - internal lateinit var binding: InputMethodViewBinding + // Bridge for BackspaceHandler to access binding through UI Manager. + internal val binding: InputMethodViewBinding + get() = uiManager.binding - private var pluralBtn: Button? = null - private var emojiBtnPhone1: Button? = null - private var emojiSpacePhone: View? = null - private var emojiBtnPhone2: Button? = null - private var emojiBtnTablet1: Button? = null - private var emojiSpaceTablet1: View? = null - private var emojiBtnTablet2: Button? = null - private var emojiSpaceTablet2: View? = null - private var emojiBtnTablet3: Button? = null - private var genderSuggestionLeft: Button? = null - private var genderSuggestionRight: Button? = null + // MARK: State Variables internal var isSingularAndPlural: Boolean = false private var subsequentAreaRequired: Boolean = false @@ -121,14 +118,17 @@ abstract class GeneralKeyboardIME( private lateinit var autocompletionHandler: AutocompletionHandler private lateinit var autocompletionManager: AutocompletionDataManager private var dataContract: DataContract? = null + var emojiKeywords: HashMap>? = null private var conjugateOutput: MutableMap>>? = null - private lateinit var conjugateLabels: Set + private var conjugateLabels: Set = emptySet() + private var emojiMaxKeywordLength: Int = 0 internal lateinit var nounKeywords: HashMap> internal lateinit var suggestionWords: HashMap> var pluralWords: Set? = null internal lateinit var caseAnnotation: HashMap> + var emojiAutoSuggestionEnabled: Boolean = false var lastWord: String? = null var autoSuggestEmojis: MutableList? = null @@ -139,87 +139,40 @@ abstract class GeneralKeyboardIME( private var currentEnterKeyType: Int? = null internal var currentState: ScribeState = ScribeState.IDLE - private var earlierValue: Int? = keyboardView?.setEnterKeyIcon(ScribeState.IDLE) - - private var currentPage = 0 - private val totalPages = 3 - private val explanationStrings = - arrayOf( - R.string.i18n_app_keyboard_not_in_wikidata_explanation_1, - R.string.i18n_app_keyboard_not_in_wikidata_explanation_2, - R.string.i18n_app_keyboard_not_in_wikidata_explanation_3, - ) - internal var currentCommandBarHint: String = "" - internal var commandBarHintColor: Int = Color.GRAY - private var commandBarTextColor: Int = Color.BLACK - - private var currentVerbForConjugation: String? = null - /** - * Safely fetches autocomplete suggestions for the given prefix. - * Returns an empty list if a database or state error occurs. - */ - fun getAutocompletions( - prefix: String, - limit: Int = 3, - ): List = - try { - dbManagers.autocompletionManager.getAutocompletions(prefix, limit) - } catch (e: SQLiteException) { - Log.e("GeneralKeyboardIME", "Database error in autocompletion", e) - emptyList() - } catch (e: IllegalStateException) { - Log.e("GeneralKeyboardIME", "Illegal state in autocompletion", e) - emptyList() + // Properties used by BackspaceHandler, delegated to UI Manager. + internal var currentCommandBarHint: String + get() = uiManager.currentCommandBarHint + set(value) { + uiManager.currentCommandBarHint = value } - /** - * This function is updated to reliably detect search bars in various apps, - * including browsers like Chrome and Firefox, not just fields with IME_ACTION_SEARCH. - * The logic is combined into a single return statement to satisfy the `detekt` ReturnCount rule. - * It checks multiple signals: - * 1. The explicit IME action for search. - * 2. The input type variation for URIs (common in address bars). - * 3. The hint text for keywords like "search" or "address". - * - * @return true if the current input field is likely a search or address bar, false otherwise. - */ - fun isSearchBar(): Boolean { - val editorInfo = currentInputEditorInfo - - val isActionSearch = (enterKeyType == EditorInfo.IME_ACTION_SEARCH) - - val isUriType = - editorInfo?.let { - (it.inputType and InputType.TYPE_TEXT_VARIATION_URI) != 0 - } == true - - val hasSearchHint = - editorInfo?.hintText?.toString()?.lowercase(Locale.ROOT)?.let { - it.contains("search") || it.contains("address") - } == true + internal var commandBarHintColor: Int + get() = uiManager.commandBarHintColor + set(value) { + uiManager.commandBarHintColor = value + } - return isActionSearch || isUriType || hasSearchHint - } + // MARK: Conjugation State - protected fun isPeriodAndCommaEnabled(): Boolean { - val isPreferenceEnabled = PreferencesHelper.getEnablePeriodAndCommaABC(this, language) - val isInSearchBar = isSearchBar() + private var currentVerbForConjugation: String? = null + private var selectedConjugationSubCategory: String? = null - return isPreferenceEnabled || isInSearchBar + internal companion object { + const val DEFAULT_SHIFT_PERM_TOGGLE_SPEED = 500 + const val TEXT_LENGTH = 20 + const val NOUN_TYPE_SIZE = 20f + const val SUGGESTION_SIZE = 15f + const val DARK_THEME = "#aeb3be" + const val LIGHT_THEME = "#4b4b4b" + internal const val MAX_TEXT_LENGTH = 1000 + const val COMMIT_TEXT_CURSOR_POSITION = 1 + internal const val CUSTOM_CURSOR = "│" // special tall cursor character } enum class ScribeState { IDLE, SELECT_COMMAND, TRANSLATE, CONJUGATE, PLURAL, SELECT_VERB_CONJUNCTION, INVALID, ALREADY_PLURAL } - /** - * Returns whether the current conjugation state requires a subsequent selection view. - * This is used, for example, when a conjugation form has multiple options (e.g., "am/is/are" in English). - * - * @return true if a subsequent selection screen is needed, false otherwise. - */ - internal fun returnIsSubsequentRequired(): Boolean = subsequentAreaRequired - - internal fun returnSubsequentData(): List> = subsequentData + // MARK: Lifecycle Methods /** * Called when the service is first created. Initializes database and suggestion handlers. @@ -230,7 +183,6 @@ abstract class GeneralKeyboardIME( suggestionHandler = SuggestionHandler(this) autocompletionManager = dbManagers.autocompletionManager autocompletionHandler = AutocompletionHandler(this) - binding = InputMethodViewBinding.inflate(layoutInflater) } /** @@ -238,37 +190,31 @@ abstract class GeneralKeyboardIME( * * @return The root View of the input method. */ - override fun onCreateInputView(): View = - try { - val inputView = binding.root - keyboardView = binding.keyboardView - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) + override fun onCreateInputView(): View { + // Initialize UI manager. + val viewBinding = InputMethodViewBinding.inflate(layoutInflater) + uiManager = KeyboardUIManager(viewBinding, this, this) + keyboardView = uiManager.keyboardView - keyboardView?.setVibrate = getIsVibrateEnabled(applicationContext, language) - keyboardView?.setSound = getIsSoundEnabled(applicationContext, language) - keyboardView?.setHoldForAltCharacters = getHoldKeyStyle(applicationContext, language) - - keyboardView!!.setKeyboard(keyboard!!) - keyboardView!!.mOnKeyboardActionListener = this + // Initial keyboard setup. + keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - initializeUiElements() - setupClickListeners() - currentState = ScribeState.IDLE - saveConjugateModeType("none") - updateUI() - - inputView - } catch (e: Resources.NotFoundException) { - Log.e("GeneralKeyboardIME", "Keyboard layout resource not found", e) - View(this) - } catch (e: InflateException) { - Log.e("GeneralKeyboardIME", "Failed to inflate keyboard view", e) - View(this) - } catch (e: IllegalStateException) { - Log.e("GeneralKeyboardIME", "Illegal state while creating input view", e) - View(this) + keyboardView?.apply { + setVibrate = getIsVibrateEnabled(applicationContext, language) + setSound = getIsSoundEnabled(applicationContext, language) + setHoldForAltCharacters = getHoldKeyStyle(applicationContext, language) + setKeyboard(this@GeneralKeyboardIME.keyboard!!) + mOnKeyboardActionListener = this@GeneralKeyboardIME } + currentState = ScribeState.IDLE + saveConjugateModeType("none") + + refreshUI() + + return viewBinding.root + } + /** * Always show the input view. Required for API 36 onwards as edge-to-edge * enforcement can cause the keyboard to not display if this returns false. @@ -291,13 +237,16 @@ abstract class GeneralKeyboardIME( */ override fun onComputeInsets(outInsets: Insets) { super.onComputeInsets(outInsets) - val inputView = binding.root - if (inputView.visibility == View.VISIBLE && inputView.height > 0) { - val location = IntArray(2) - inputView.getLocationInWindow(location) - outInsets.visibleTopInsets = location[1] - outInsets.contentTopInsets = location[1] - outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE + // Access root view via UI manager if initialized. + if (this::uiManager.isInitialized) { + val inputView = uiManager.binding.root + if (inputView.visibility == View.VISIBLE && inputView.height > 0) { + val location = IntArray(2) + inputView.getLocationInWindow(location) + outInsets.visibleTopInsets = location[1] + outInsets.contentTopInsets = location[1] + outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE + } } } @@ -309,155 +258,6 @@ abstract class GeneralKeyboardIME( keyboardView?.setHoldForAltCharacters = getHoldKeyStyle(applicationContext, language) } - /** - * Finds and initializes UI elements from the inflated view binding. - */ - private fun initializeUiElements() { - pluralBtn = binding.pluralBtn - emojiBtnPhone1 = binding.emojiBtnPhone1 - emojiSpacePhone = binding.emojiSpacePhone - emojiBtnPhone2 = binding.emojiBtnPhone2 - emojiBtnTablet1 = binding.emojiBtnTablet1 - emojiSpaceTablet1 = binding.emojiSpaceTablet1 - emojiBtnTablet2 = binding.emojiBtnTablet2 - emojiSpaceTablet2 = binding.emojiSpaceTablet2 - emojiBtnTablet3 = binding.emojiBtnTablet3 - genderSuggestionLeft = binding.translateBtnLeft - genderSuggestionRight = binding.translateBtnRight - } - - /** - * Sets up the OnClickListeners for the main interactive elements of the Scribe key and toolbar. - */ - private fun setupClickListeners() { - binding.scribeKeyOptions.setOnClickListener { - if (currentState == ScribeState.IDLE || currentState == ScribeState.SELECT_COMMAND) { - if (currentState == ScribeState.IDLE) { - moveToSelectCommandState() - } else { - moveToIdleState() - } - } - } - - setCommandButtonListeners() - binding.scribeKeyToolbar.setOnClickListener { moveToIdleState() } - } - - /** - * Attaches OnClickListeners to the command buttons (Translate, Conjugate, Plural). - */ - private fun setCommandButtonListeners() { - binding.translateBtn.setOnClickListener { - currentState = ScribeState.TRANSLATE - saveConjugateModeType("none") - updateUI() - } - binding.conjugateBtn.setOnClickListener { - currentState = ScribeState.CONJUGATE - updateUI() - } - binding.pluralBtn.setOnClickListener { - currentState = ScribeState.PLURAL - saveConjugateModeType("none") - if (language == "German") keyboard?.mShiftState = SHIFT_ON_ONE_CHAR - updateUI() - } - } - - /** - * Called when the input view is finished. Resets the keyboard state to idle. - * - * @param finishingInput true if we are finishing for good, - * `false` if just switching to another app. - */ - override fun onFinishInputView(finishingInput: Boolean) { - super.onFinishInputView(finishingInput) - moveToIdleState() - } - - /** - * Called by the system when the service is first initialized, before the input view is created. - * Initializes the base keyboard and updates the UI if the view is already bound. - */ - override fun onInitializeInterface() { - super.onInitializeInterface() - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - if (this::binding.isInitialized) updateUI() - } - - /** - * Overrides the default implementation to check if there is any - * non-whitespace text before the cursor. - * - * @return true if there is meaningful text before the cursor, false otherwise. - */ - override fun hasTextBeforeCursor(): Boolean { - val ic = currentInputConnection ?: return false - val text = ic.getTextBeforeCursor(Int.MAX_VALUE, 0)?.trim() ?: "" - return text.isNotEmpty() && text.lastOrNull() != '.' - } - - /** - * Called when a key is pressed down. Triggers haptic feedback if enabled. - * - * @param primaryCode The integer code of the key that was pressed. - */ - override fun onPress(primaryCode: Int) { - if (primaryCode != 0) keyboardView?.vibrateIfNeeded() - if (primaryCode != 0) keyboardView?.soundIfNeeded() - } - - /** - * Called when a key is released. Handles the logic - * to switch back to the letter keyboard - * after typing a character from the symbol keyboard. - */ - override fun onActionUp() { - if (switchToLetters) { - keyboardMode = keyboardLetters - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - val editorInfo = currentInputEditorInfo - if ( - editorInfo != null && - editorInfo.inputType != InputType.TYPE_NULL && - keyboard?.mShiftState != SHIFT_ON_PERMANENT - ) { - if (currentInputConnection.getCursorCapsMode(editorInfo.inputType) != 0) { - keyboard?.setShifted(SHIFT_ON_ONE_CHAR) - } - } - keyboardView!!.setKeyboard(keyboard!!) - switchToLetters = false - } - } - - /** - * Sets the flag to indicate that the delete key is currently repeating (long press). - * Delegated to BackspaceHandler. - */ - fun setDeleteRepeating(isRepeating: Boolean) { - backspaceHandler.isDeleteRepeating = isRepeating - } - - /** - * Returns whether the delete key is currently repeating (long press). - * Delegated to BackspaceHandler. - */ - fun isDeleteRepeating(): Boolean = backspaceHandler.isDeleteRepeating - - override fun moveCursorLeft() { - moveCursor(false) - } - - override fun moveCursorRight() { - moveCursor(true) - } - - override fun onText(text: String) { - currentInputConnection?.commitText(text, 0) - } - /** * Called when the IME is starting to interact with a new input field. * It initializes the keyboard based on the input type and loads all language-specific data. @@ -473,7 +273,10 @@ abstract class GeneralKeyboardIME( inputTypeClass = attribute!!.inputType and TYPE_MASK_CLASS enterKeyType = attribute.imeOptions and (IME_MASK_ACTION or IME_FLAG_NO_ENTER_ACTION) currentEnterKeyType = enterKeyType + + // This setter triggers the logic in the property override if not shadowed. hasTextBeforeCursor = currentInputConnection?.getTextBeforeCursor(1, 0)?.isNotEmpty() == true + val keyboardXml = when (inputTypeClass) { TYPE_CLASS_NUMBER, TYPE_CLASS_DATETIME, TYPE_CLASS_PHONE -> { @@ -485,28 +288,14 @@ abstract class GeneralKeyboardIME( getKeyboardLayoutXML() } } - val languageAlias = getLanguageAlias(language) - dataContract = dbManagers.getLanguageContract(languageAlias) - emojiKeywords = dbManagers.emojiManager.getEmojiKeywords(languageAlias) - emojiMaxKeywordLength = dbManagers.emojiManager.maxKeywordLength - pluralWords = - dbManagers.pluralManager - .getAllPluralForms(languageAlias, dataContract) - ?.map { it.lowercase() } - ?.toSet() - nounKeywords = dbManagers.genderManager.findGenderOfWord(languageAlias, dataContract) - suggestionWords = dbManagers.suggestionManager.getSuggestions(languageAlias) - autocompletionManager.loadWords(languageAlias) - caseAnnotation = dbManagers.prepositionManager.getCaseAnnotations(languageAlias) - val tempConjugateOutput = dbManagers.conjugateDataManager.getTheConjugateLabels(languageAlias, dataContract, "describe") - conjugateOutput = if (tempConjugateOutput?.isEmpty() == true) null else tempConjugateOutput - conjugateLabels = dbManagers.conjugateDataManager.extractConjugateHeadings(dataContract, "coacha") + + loadLanguageData() + keyboard = KeyboardBase(this, keyboardXml, enterKeyType) keyboardView?.setKeyboard(keyboard!!) - // Set up the currency symbol if we're using the symbols keyboard layout. if (keyboardXml == R.xml.keys_symbols) { - setupCurrencySymbol() + uiManager.setupCurrencySymbol(language) } } @@ -522,26 +311,19 @@ abstract class GeneralKeyboardIME( restarting: Boolean, ) { super.onStartInputView(editorInfo, restarting) - val isUserDarkMode = getIsDarkModeOrNot(applicationContext) - updateEnterKeyColor(isUserDarkMode) emojiAutoSuggestionEnabled = getIsEmojiSuggestionsEnabled(applicationContext, language) - autoSuggestEmojis = null suggestionHandler.clearAllSuggestionsAndHideButtonUI() moveToIdleState() + val window = window?.window ?: return - var color = R.color.dark_keyboard_bg_color val isDarkMode = getIsDarkModeOrNot(applicationContext) - color = - if (isDarkMode) { - R.color.dark_keyboard_bg_color - } else { - R.color.light_keyboard_bg_color - } + val color = if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color window.navigationBarColor = ContextCompat.getColor(this, color) + // Handle Edge-to-Edge Navigation Bar icons color. val decorView = window.decorView var flags = decorView.systemUiVisibility flags = @@ -551,16 +333,30 @@ abstract class GeneralKeyboardIME( flags and View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv() } decorView.systemUiVisibility = flags + val textBefore = currentInputConnection?.getTextBeforeCursor(1, 0)?.toString().orEmpty() if (textBefore.isEmpty()) keyboard?.setShifted(SHIFT_ON_ONE_CHAR) } - private fun isLightColor(color: Int): Boolean { - val darkness = - 1 - (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255 - return darkness < 0.5 + /** + * Called when the input view is finished. Resets the keyboard state to idle. + * + * @param finishingInput true if we are finishing for good, + * `false` if just switching to another app. + */ + override fun onFinishInputView(finishingInput: Boolean) { + super.onFinishInputView(finishingInput) + moveToIdleState() } + // MARK: OnKeyboardActionListener + + /** + * Interface method called by KeyboardView. + * Delegates to the property 'hasTextBeforeCursor' which subclasses may override. + */ + override fun hasTextBeforeCursor(): Boolean = hasTextBeforeCursor + /** * Handles the "period on double tap" feature. If enabled, it replaces the two spaces with a period and a space. */ @@ -582,966 +378,735 @@ abstract class GeneralKeyboardIME( } /** - * Updates the color of the Enter key based on the current Scribe state and theme (dark/light mode). + * Called when a key is pressed down. Triggers haptic feedback if enabled. * - * @param isDarkMode The current dark mode status. If null, it will be determined from context. + * @param primaryCode The integer code of the key that was pressed. */ - private fun updateEnterKeyColor(isDarkMode: Boolean?) { - val resolvedIsDarkMode = isDarkMode ?: getIsDarkModeOrNot(applicationContext) - when (currentState) { - ScribeState.IDLE, ScribeState.SELECT_COMMAND -> { - keyboardView?.setEnterKeyIcon(ScribeState.IDLE, earlierValue) - keyboardView?.setEnterKeyColor(null, isDarkMode = resolvedIsDarkMode) - } - else -> { - keyboardView?.setEnterKeyColor(getColor(R.color.color_primary)) - keyboardView?.setEnterKeyIcon(ScribeState.PLURAL, earlierValue) - } - } - val scribeKeyTint = if (resolvedIsDarkMode) R.color.light_key_color else R.color.light_key_text_color - binding.scribeKeyOptions.foregroundTintList = ContextCompat.getColorStateList(this, scribeKeyTint) - binding.scribeKeyToolbar.foregroundTintList = ContextCompat.getColorStateList(this, scribeKeyTint) + override fun onPress(primaryCode: Int) { + if (primaryCode != 0) keyboardView?.vibrateIfNeeded() + if (primaryCode != 0) keyboardView?.soundIfNeeded() } /** - * Updates the hint and prompt text displayed in the command bar area based on the current state. - * - * @param isUserDarkMode The current dark mode status. If null, it will be determined from context. - * @param text A specific text to be displayed in the prompt, often used for conjugation titles. - * @param word A word to be included in the hint text. + * Called when a key is released. Handles the logic + * to switch back to the letter keyboard + * after typing a character from the symbol keyboard. */ - @SuppressLint("SetTextI18n") - private fun updateCommandBarHintAndPrompt( - isUserDarkMode: Boolean? = null, - text: String? = null, - word: String? = null, - ) { - val resolvedIsDarkMode = isUserDarkMode ?: getIsDarkModeOrNot(applicationContext) - val commandBarEditText = binding.commandBar - val promptTextView = binding.promptText - - // Set up colors and background. - commandBarHintColor = if (resolvedIsDarkMode) getColor(R.color.hint_white) else getColor(R.color.hint_black) - commandBarTextColor = if (resolvedIsDarkMode) getColor(white) else Color.BLACK - val backgroundColor = if (resolvedIsDarkMode) R.color.command_bar_color_dark else white - binding.commandBarLayout.backgroundTintList = ContextCompat.getColorStateList(this, backgroundColor) - - // Set prompt text. - val promptText = HintUtils.getPromptText(currentState, language, context = this, text) - promptTextView.text = promptText - promptTextView.setTextColor(commandBarTextColor) - promptTextView.setBackgroundColor(getColor(backgroundColor)) - - if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { - // Set to the verb that the user can select options for. - val verbInfinitive = currentVerbForConjugation ?: "" - - commandBarEditText.setText(": $verbInfinitive") - commandBarEditText.setTextColor(commandBarTextColor) + override fun onActionUp() { + if (switchToLetters) { + keyboardMode = keyboardLetters + keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) + val editorInfo = currentInputEditorInfo + if (editorInfo != null && editorInfo.inputType != InputType.TYPE_NULL && keyboard?.mShiftState != SHIFT_ON_PERMANENT) { + if (currentInputConnection.getCursorCapsMode(editorInfo.inputType) != 0) { + keyboard?.setShifted(SHIFT_ON_ONE_CHAR) + } + } + keyboardView!!.setKeyboard(keyboard!!) + switchToLetters = false + } + } - commandBarEditText.isFocusable = false - commandBarEditText.isFocusableInTouchMode = false - } else { - // Default for Plural, Translate, etc. where user needs to type. - currentCommandBarHint = HintUtils.getCommandBarHint(currentState, language, word) + override fun moveCursorLeft() = moveCursor(false) - // Make sure the command bar is editable again. - commandBarEditText.isFocusable = true - commandBarEditText.isFocusableInTouchMode = true + override fun moveCursorRight() = moveCursor(true) - // Set the fake hint with the custom cursor. - commandBarEditText.setTextColor(commandBarHintColor) - setCommandBarTextWithCursor(currentCommandBarHint, cursorAtStart = true) - commandBarEditText.requestFocus() - } + override fun onText(text: String) { + currentInputConnection?.commitText(text, 0) } /** - * The main dispatcher for updating the entire keyboard UI. It calls the appropriate setup function - * based on the current [ScribeState]. + * Handles key input from the keyboard. Delegates to specific handlers based on the key code. */ - internal fun updateUI() { - if (!this::binding.isInitialized) return - val isUserDarkMode = getIsDarkModeOrNot(applicationContext) - when (currentState) { - ScribeState.IDLE -> { - setupIdleView() - } - ScribeState.SELECT_COMMAND -> { - setupSelectCommandView() - } - - ScribeState.INVALID -> setupInvalidView() - ScribeState.TRANSLATE -> { - setupToolbarView() - binding.translateBtn.text = translatePlaceholder[getLanguageAlias(language)] ?: "Translate" - binding.translateBtn.visibility = View.VISIBLE + override fun onKey(code: Int) { + val inputConnection = currentInputConnection + if (inputConnection != null) { + when (code) { + KeyboardBase.KEYCODE_DELETE -> handleDelete() + KeyboardBase.KEYCODE_SHIFT -> { + keyboard?.let { + if (keyboardMode == keyboardLetters) { + when { + it.mShiftState == SHIFT_ON_PERMANENT -> it.mShiftState = SHIFT_OFF + System.currentTimeMillis() - lastShiftPressTS < shiftPermToggleSpeed -> it.mShiftState = SHIFT_ON_PERMANENT + it.mShiftState == SHIFT_ON_ONE_CHAR -> it.mShiftState = SHIFT_OFF + it.mShiftState == SHIFT_OFF -> it.mShiftState = SHIFT_ON_ONE_CHAR + } + lastShiftPressTS = System.currentTimeMillis() + } else { + handleModeChange(keyboardMode, keyboardView, this) + } + } + keyboardView?.invalidateAllKeys() + } + KeyboardBase.KEYCODE_ENTER -> handleKeycodeEnter() + KeyboardBase.KEYCODE_MODE_CHANGE -> handleModeChange(keyboardMode, keyboardView, this) + else -> { + if (KeyboardBase.SCRIBE_VIEW_KEYS.contains(code)) { + val keyLabel = keyboardView?.getKeyLabel(code) + if (!keyLabel.isNullOrEmpty()) { + commitText("$keyLabel ") + } + } else { + val commandBarState = currentState != ScribeState.IDLE && currentState != ScribeState.SELECT_COMMAND + handleElseCondition(code, keyboardMode, commandBarState) + } + } } - ScribeState.CONJUGATE -> setupToolbarView() - ScribeState.SELECT_VERB_CONJUNCTION -> setupToolbarView() - ScribeState.PLURAL -> setupToolbarView() - ScribeState.ALREADY_PLURAL -> setupAlreadyPluralView() } + } - updateEnterKeyColor(isUserDarkMode) + // MARK: Helper Methods + + protected fun isPeriodAndCommaEnabled(): Boolean { + val isPreferenceEnabled = PreferencesHelper.getEnablePeriodAndCommaABC(this, language) + val isInSearchBar = isSearchBar() + return isPreferenceEnabled || isInSearchBar } /** - * Configures the UI for the `IDLE` state, showing default suggestions or emoji suggestions. + * This function is updated to reliably detect search bars in various apps, + * including browsers like Chrome and Firefox, not just fields with IME_ACTION_SEARCH. + * The logic is combined into a single return statement to satisfy the `detekt` ReturnCount rule. + * It checks multiple signals: + * 1. The explicit IME action for search. + * 2. The input type variation for URIs (common in address bars). + * 3. The hint text for keywords like "search" or "address". + * + * @return true if the current input field is likely a search or address bar, false otherwise. */ - private fun setupIdleView() { - binding.commandOptionsBar.visibility = View.VISIBLE - binding.toolbarBar.visibility = View.GONE + fun isSearchBar(): Boolean { + val editorInfo = currentInputEditorInfo + val isActionSearch = (enterKeyType == EditorInfo.IME_ACTION_SEARCH) + val isUriType = editorInfo?.let { (it.inputType and InputType.TYPE_TEXT_VARIATION_URI) != 0 } == true + val hasSearchHint = + editorInfo?.hintText?.toString()?.lowercase(Locale.ROOT)?.let { + it.contains("search") || it.contains("address") + } == true + return isActionSearch || isUriType || hasSearchHint + } - val isUserDarkMode = getIsDarkModeOrNot(applicationContext) + private fun loadLanguageData() { + val languageAlias = getLanguageAlias(language) + dataContract = dbManagers.getLanguageContract(languageAlias) + emojiKeywords = dbManagers.emojiManager.getEmojiKeywords(languageAlias) + emojiMaxKeywordLength = dbManagers.emojiManager.maxKeywordLength + pluralWords = + dbManagers.pluralManager + .getAllPluralForms(languageAlias, dataContract) + ?.map { it.lowercase() } + ?.toSet() + nounKeywords = dbManagers.genderManager.findGenderOfWord(languageAlias, dataContract) + suggestionWords = dbManagers.suggestionManager.getSuggestions(languageAlias) + autocompletionManager.loadWords(languageAlias) + caseAnnotation = dbManagers.prepositionManager.getCaseAnnotations(languageAlias) - binding.commandOptionsBar.setBackgroundColor( - ContextCompat.getColor( - this, - if (isUserDarkMode) { - R.color.dark_keyboard_bg_color - } else { - R.color.light_keyboard_bg_color - }, - ), - ) + val tempConjugateOutput = dbManagers.conjugateDataManager.getTheConjugateLabels(languageAlias, dataContract, "describe") + conjugateOutput = if (tempConjugateOutput?.isEmpty() == true) null else tempConjugateOutput + conjugateLabels = dbManagers.conjugateDataManager.extractConjugateHeadings(dataContract, "coacha") + } - val textColor = if (isUserDarkMode) Color.WHITE else "#1E1E1E".toColorInt() + private fun isLightColor(color: Int): Boolean { + val darkness = 1 - (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255 + return darkness < 0.5 + } - listOf(binding.translateBtn, binding.conjugateBtn, binding.pluralBtn).forEachIndexed { index, button -> - button.visibility = View.VISIBLE - button.background = null - button.setTextColor(textColor) - button.text = HintUtils.getBaseAutoSuggestions(language).getOrNull(index) - button.isAllCaps = false - button.textSize = SUGGESTION_SIZE - button.setOnClickListener(null) - } + /** + * Saves the type of conjugation layout being used (e.g., "2x2", "3x2") to shared preferences. + * + * @param language The current keyboard language. + * @param isSubsequentArea true if this is for a secondary view. + */ + internal fun saveConjugateModeType( + language: String, + isSubsequentArea: Boolean = false, + ) { + val sharedPref = applicationContext.getSharedPreferences("keyboard_preferences", MODE_PRIVATE) + val mode = + if (!isSubsequentArea) { + when (language) { + "English", "Russian", "Swedish" -> "2x2" + "German", "French", "Italian", "Portuguese", "Spanish" -> "3x2" + else -> "none" + } + } else { + "none" + } + sharedPref.edit { putString("conjugate_mode_type", mode) } + } - listOf(binding.separator2, binding.separator3).forEach { separator -> - separator.setBackgroundColor(ContextCompat.getColor(this, R.color.special_key_light)) - val params = separator.layoutParams - // Convert 0.5dp to pixels. coerceAtLeast(1) ensures it's never zero. - params.width = (SEPARATOR_WIDTH * resources.displayMetrics.density).toInt().coerceAtLeast(1) - separator.layoutParams = params + // MARK: UI Update Delegation - separator.visibility = View.VISIBLE - } + /** + * The main dispatcher for updating the entire keyboard UI. It calls the appropriate setup function + * based on the current [ScribeState]. + */ + internal fun updateUI() = refreshUI() - binding.separator1.visibility = View.GONE - binding.ivInfo.visibility = View.GONE - binding.root.findViewById(R.id.conjugate_grid_container).visibility = View.GONE - binding.keyboardView.visibility = View.VISIBLE + private fun refreshUI() { + if (!this::uiManager.isInitialized) return - binding.scribeKeyOptions.foreground = AppCompatResources.getDrawable(this, R.drawable.ic_scribe_icon_vector) - initializeKeyboard(getKeyboardLayoutXML()) - updateButtonVisibility(emojiAutoSuggestionEnabled) - updateEmojiSuggestion(emojiAutoSuggestionEnabled, autoSuggestEmojis) - binding.commandBar.setText("") - disableAutoSuggest() + uiManager.updateUI( + currentState = currentState, + language = language, + emojiAutoSuggestionEnabled = emojiAutoSuggestionEnabled, + autoSuggestEmojis = autoSuggestEmojis, + conjugateOutput = conjugateOutput, + conjugateLabels = conjugateLabels, + selectedConjugationSubCategory = selectedConjugationSubCategory, + currentVerbForConjugation = currentVerbForConjugation, + ) } /** - * Configures the UI for the `SELECT_COMMAND` state, showing the main command buttons - * (Translate, Conjugate, Plural). + * Transitions the keyboard to the `IDLE` state and updates the UI. */ - private fun setupSelectCommandView() { - binding.commandOptionsBar.visibility = View.VISIBLE - binding.toolbarBar.visibility = View.GONE - - val isUserDarkMode = getIsDarkModeOrNot(applicationContext) + internal fun moveToIdleState() { + clearSuggestionData() + currentState = ScribeState.IDLE + saveConjugateModeType("none") + currentVerbForConjugation = null + selectedConjugationSubCategory = null + if (this::uiManager.isInitialized) refreshUI() + } - binding.commandOptionsBar.setBackgroundColor( - ContextCompat.getColor( - this, - if (isUserDarkMode) { - R.color.dark_keyboard_bg_color - } else { - R.color.light_keyboard_bg_color - }, - ), - ) + /** + * Clears all cached suggestion data. + */ + private fun clearSuggestionData() { + autoSuggestEmojis = null + nounTypeSuggestion = null + caseAnnotationSuggestion = null + isSingularAndPlural = false + } - val langAlias = getLanguageAlias(language) + // MARK: KeyboardUIListener - updateButtonVisibility(isAutoSuggestEnabled = false) - setCommandButtonListeners() + override fun onScribeKeyOptionsClicked() { + if (currentState == ScribeState.IDLE) { + clearSuggestionData() + currentState = ScribeState.SELECT_COMMAND + saveConjugateModeType("none") + currentVerbForConjugation = null + } else { + moveToIdleState() + } + refreshUI() + } - val buttonTextColor = if (isUserDarkMode) Color.WHITE else Color.BLACK + override fun onScribeKeyToolbarClicked() { + moveToIdleState() + } - listOf(binding.translateBtn, binding.conjugateBtn, binding.pluralBtn).forEach { button -> - button.visibility = View.VISIBLE - button.background = ContextCompat.getDrawable(this, R.drawable.button_background_rounded) - button.backgroundTintList = ContextCompat.getColorStateList(this, R.color.theme_scribe_blue) - button.setTextColor(buttonTextColor) - button.textSize = SUGGESTION_SIZE - } + override fun onTranslateClicked() { + currentState = ScribeState.TRANSLATE + saveConjugateModeType("none") + refreshUI() + } - binding.translateBtn.text = translatePlaceholder[langAlias] ?: "Translate" - binding.conjugateBtn.text = conjugatePlaceholder[langAlias] ?: "Conjugate" - binding.pluralBtn.text = pluralPlaceholder[langAlias] ?: "Plural" - - val separatorColor = (if (isUserDarkMode) DARK_THEME else LIGHT_THEME).toColorInt() - binding.separator2.setBackgroundColor(separatorColor) - binding.separator3.setBackgroundColor(separatorColor) - - val spaceInDp = COMMAND_BUTTON_SPACING_DP - val spaceInPx = (spaceInDp * resources.displayMetrics.density).toInt() - listOf(binding.separator2, binding.separator3).forEach { separator -> - separator.setBackgroundColor(Color.TRANSPARENT) - val params = separator.layoutParams - params.width = spaceInPx - separator.layoutParams = params - } + override fun onConjugateClicked() { + currentState = ScribeState.CONJUGATE + refreshUI() + } - binding.separator1.visibility = View.GONE - binding.separator2.visibility = View.VISIBLE - binding.separator3.visibility = View.VISIBLE - binding.separator4.visibility = View.GONE - binding.separator5.visibility = View.GONE - binding.separator6.visibility = View.GONE - binding.ivInfo.visibility = View.GONE - binding.scribeKeyOptions.foreground = AppCompatResources.getDrawable(this, R.drawable.close) + override fun onPluralClicked() { + currentState = ScribeState.PLURAL + saveConjugateModeType("none") + if (language == "German") keyboard?.mShiftState = SHIFT_ON_ONE_CHAR + refreshUI() } - private var selectedConjugationSubCategory: String? = null + override fun onCloseClicked() { + moveToIdleState() + } - /** - * Configures the UI for command modes (`TRANSLATE`, `CONJUGATE`, etc.), showing the command bar and toolbar. - */ - private fun setupToolbarView() { - binding.commandOptionsBar.visibility = View.GONE - binding.toolbarBar.visibility = View.VISIBLE - val isDarkMode = getIsDarkModeOrNot(applicationContext) - binding.toolbarBar.setBackgroundColor( - ContextCompat.getColor( - this, - if (isDarkMode) { - R.color.dark_keyboard_bg_color - } else { - R.color.light_keyboard_bg_color - }, - ), - ) - binding.ivInfo.visibility = View.GONE + override fun onEmojiSelected(emoji: String) { + if (emoji.isNotEmpty()) { + insertEmoji(emoji, currentInputConnection, emojiKeywords, emojiMaxKeywordLength) + } + } - binding.scribeKeyToolbar.foreground = - AppCompatResources.getDrawable( - this, - R.drawable.close, - ) + override fun onSuggestionClicked(suggestion: String) { + currentInputConnection?.commitText("$suggestion ", 1) + moveToIdleState() + } - var hintWord: String? = null - var promptText: String? = null + override fun getCurrentEnterKeyType(): Int = enterKeyType - // Show/hide conjugate grid based on state. - if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { - // Show conjugate_grid_container (1/3 screen size) between toolbar and keyboard. - binding.root.findViewById(R.id.conjugate_grid_container).visibility = View.VISIBLE - binding.keyboardView.visibility = View.GONE + override fun onKeyboardActionListener(): KeyboardView.OnKeyboardActionListener = this - // Populate the grid with appropriate layout based on language. - val grid = binding.root.findViewById(R.id.conjugate_grid) - grid.removeAllViews() + override fun processLinguisticSuggestions(word: String) { + suggestionHandler.processLinguisticSuggestions(word) + } + override fun commitText(text: String) { + if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { + val label = text.trim() val conjugateIndex = getValidatedConjugateIndex() val title = conjugateOutput?.keys?.elementAtOrNull(conjugateIndex) val languageOutput = title?.let { conjugateOutput!![it] } - // Determine if we are in sub-category selection mode. - val isSubSelection = selectedConjugationSubCategory != null - val showCategories = !isSubSelection && (languageOutput?.containsKey(title) != true) - - val forms = - if (isSubSelection) { - languageOutput?.get(selectedConjugationSubCategory)?.toList() ?: listOf("", "", "", "") - } else if (showCategories) { - languageOutput?.map { (_, values) -> - if (values.size == 1) values.first() else values.joinToString(" / ") - } ?: listOf("", "", "", "") - } else { - languageOutput?.get(title)?.toList() ?: listOf("", "", "", "") - } - - val layoutResId = - when { - isSubSelection -> R.layout.conjugate_grid_2x1 - language == "English" && forms.size <= 4 -> R.layout.conjugate_grid_2x2 - language in listOf("Russian", "Swedish") && forms.size <= 4 -> R.layout.conjugate_grid_2x2 - forms.size > 4 -> R.layout.conjugate_grid_3x2 - else -> R.layout.conjugate_grid_2x2 - } - - val gridContent = layoutInflater.inflate(layoutResId, grid, false) as LinearLayout - grid.addView(gridContent) - - val buttonIds = - listOf( - R.id.conjugate_btn_1, - R.id.conjugate_btn_2, - R.id.conjugate_btn_3, - R.id.conjugate_btn_4, - R.id.conjugate_btn_5, - R.id.conjugate_btn_6, - ) - - buttonIds.forEachIndexed { i, btnId -> - val btn = gridContent.findViewById(btnId) - if (btn != null) { - btn.text = forms.getOrNull(i) ?: "" - btn.setOnClickListener { - val label = btn.text.toString() - if (label.isNotEmpty()) { - var handledAsCategory = false - if (showCategories) { - // Find which category this label corresponds to. - val matchingEntry = - languageOutput?.entries?.find { (_, values) -> - if (values.size == 1) { - values.first() == label - } else { - values.joinToString(" / ") == label - } - } - - if (matchingEntry != null) { - val (key, values) = matchingEntry - if (values.size > 1) { - // It corresponds to a multi-value category -> Enter sub-category. - selectedConjugationSubCategory = key - updateUI() - handledAsCategory = true - } - // If values.size == 1, we fall through to the commit logic below. - } - } - - if (!handledAsCategory) { - currentInputConnection?.commitText("$label ", 1) - suggestionHandler.processLinguisticSuggestions(label) - currentState = ScribeState.IDLE - binding.root.findViewById(R.id.conjugate_grid_container).visibility = View.GONE - binding.keyboardView.visibility = View.VISIBLE - selectedConjugationSubCategory = null - moveToIdleState() - } - } - } + val matchingEntry = + languageOutput?.entries?.find { (_, values) -> + if (values.size == 1) values.first() == label else values.joinToString(" / ") == label } - } - - val arrowButtonIds = - listOf( - "conjugate_arrow_left_1", - "conjugate_arrow_right_1", - "conjugate_arrow_left_2", - "conjugate_arrow_right_2", - "conjugate_arrow_left_3", - "conjugate_arrow_right_3", - "conjugate_arrow_left", - "conjugate_arrow_right", - ) - arrowButtonIds.forEach { arrowBtnName -> - val arrowBtnId = resources.getIdentifier(arrowBtnName, "id", packageName) - if (arrowBtnId != 0) { - val arrowBtn = gridContent.findViewById(arrowBtnId) - if (arrowBtn != null) { - arrowBtn.setOnClickListener { - selectedConjugationSubCategory = null - - val isLeft = arrowBtnName.contains("left") - val prefs = applicationContext.getSharedPreferences("keyboard_preferences", MODE_PRIVATE) - val current = prefs.getInt("conjugate_index", 0) - if (isLeft) { - prefs.edit { putInt("conjugate_index", current - 1) } - } else { - prefs.edit { putInt("conjugate_index", current + 1) } - } - updateUI() - } - } + if (matchingEntry != null) { + val (key, values) = matchingEntry + if (values.size > 1) { + selectedConjugationSubCategory = key + refreshUI() + return } } - - promptText = if (isSubSelection) selectedConjugationSubCategory else (title ?: "___") - hintWord = conjugateLabels.lastOrNull() - - val prefs = applicationContext.getSharedPreferences("keyboard_preferences", MODE_PRIVATE) - } else { - binding.root.findViewById(R.id.conjugate_grid_container).visibility = View.GONE - binding.keyboardView.visibility = View.VISIBLE - } - - binding.scribeKeyToolbar.setOnClickListener { - if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { - currentState = ScribeState.IDLE - moveToIdleState() - } else { - moveToIdleState() - } } - updateCommandBarHintAndPrompt(text = promptText, isUserDarkMode = isDarkMode, word = hintWord) - } + currentInputConnection?.commitText(text, 1) + suggestionHandler.processLinguisticSuggestions(text.trim()) - /** - * Configures the UI for the `INVALID` state, which is shown when a command (e.g., translation) fails. - */ - @SuppressLint("SetTextI18n") - private fun setupInvalidView() { - binding.commandOptionsBar.visibility = View.GONE - binding.toolbarBar.visibility = View.VISIBLE - val isDarkMode = getIsDarkModeOrNot(applicationContext) - binding.toolbarBar.setBackgroundColor(if (isDarkMode) "#1E1E1E".toColorInt() else "#d2d4da".toColorInt()) - binding.ivInfo.visibility = View.VISIBLE - binding.promptText.text = HintUtils.getInvalidHint(language = language) + ": " - binding.commandBar.hint = "" - binding.scribeKeyToolbar.foreground = AppCompatResources.getDrawable(this, R.drawable.ic_scribe_icon_vector) - binding.scribeKeyToolbar.setOnClickListener { moveToSelectCommandState() } - binding.ivInfo.setOnClickListener { showInvalidInfo() } - binding.scribeKeyClose.setOnClickListener { - hideInvalidInfo() + if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { + selectedConjugationSubCategory = null moveToIdleState() } } - /** - * Hide information about Wikidata and/or invalid state field. - */ - private fun hideInvalidInfo() { - binding.ivInfo.isClickable = true - binding.ivInfo.isFocusable = true - keyboardView?.findViewById(R.id.keyboard_view)?.visibility = View.VISIBLE - binding.invalidInfoBar.visibility = View.GONE - binding.invalidText.text = HintUtils.getInvalidHint(language = language) - } + // MARK: Input Logic /** - * Show information about Wikidata when the user clicks the information icon. + * 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. */ - private fun showInvalidInfo() { - binding.ivInfo.isClickable = true - binding.ivInfo.isFocusable = true - keyboardView?.findViewById(R.id.keyboard_view)?.visibility = View.GONE - binding.invalidInfoBar.visibility = View.VISIBLE - binding.invalidText.text = HintUtils.getInvalidHint(language = language) - setupWikidataButtons() - updateWikidataPage() - } + fun handleKeycodeEnter() { + val inputConnection = currentInputConnection ?: return - /** - * Set navigation functionality with the left and right arrow button. - */ - private fun setupWikidataButtons() { - binding.invalidInfoBar.findViewById