diff --git a/CHANGELOG.md b/CHANGELOG.md index c40eff410..140c49098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ Emojis for the following are chosen based on [gitmoji](https://gitmoji.dev/). - Vibrate on keypress and key click functionalities are included ([#405](https://github.com/scribe-org/Scribe-Android/issues/405), [#406](https://github.com/scribe-org/Scribe-Android/issues/406)). - An in-app tutorial is provided to detail functionalities of the application ([#602](https://github.com/scribe-org/Scribe-Android/issues/602), [#615](https://github.com/scribe-org/Scribe-Android/issues/615), [#616](https://github.com/scribe-org/Scribe-Android/issues/616)). - The user is able to easily rate the application ([#165](https://github.com/scribe-org/Scribe-Android/issues/165), [#640](https://github.com/scribe-org/Scribe-Android/issues/640)). +- The keyboard UI has been rewritten in Jetpack Compose, replacing the legacy XML view hierarchy ([#657](https://github.com/scribe-org/Scribe-Android/issues/657)). +- Emojis can be searched by keyword from a dedicated suggestion row after typing a standalone colon ([#590](https://github.com/scribe-org/Scribe-Android/issues/590)). +- An autocompletion that unambiguously matches what the user has typed is highlighted and can be accepted with the space key ([#648](https://github.com/scribe-org/Scribe-Android/pull/648)). ### 🗃️ Data @@ -67,6 +70,7 @@ Emojis for the following are chosen based on [gitmoji](https://gitmoji.dev/). - The return key is colored Scribe blue when commands are being triggered to let the user know that that is what they need to press to finish the command ([#160](https://github.com/scribe-org/Scribe-Android/issues/160)). - Dark mode compatibility through a responsive color scheme ([#25](https://github.com/scribe-org/Scribe-Android/issues/25), [#51](https://github.com/scribe-org/Scribe-Android/issues/51), [#116](https://github.com/scribe-org/Scribe-Android/issues/116), [#121](https://github.com/scribe-org/Scribe-Android/issues/121), [#155](https://github.com/scribe-org/Scribe-Android/issues/155), [#161](https://github.com/scribe-org/Scribe-Android/issues/161), [#543](https://github.com/scribe-org/Scribe-Android/issues/543)). - The application menu follows modern Android styling ([#114](https://github.com/scribe-org/Scribe-Android/issues/114), [#150](https://github.com/scribe-org/Scribe-Android/issues/150), [#217](https://github.com/scribe-org/Scribe-Android/issues/217), [#246](https://github.com/scribe-org/Scribe-Android/issues/246), [#247](https://github.com/scribe-org/Scribe-Android/issues/247), [248](https://github.com/scribe-org/Scribe-Android/issues/248), [#256](https://github.com/scribe-org/Scribe-Android/issues/256)). +- The floating keyboard can be dragged around the screen and resized from its corner handles ([#261](https://github.com/scribe-org/Scribe-Android/issues/261)). ### 🌐 Localization diff --git a/app/src/androidTestKeyboards/kotlin/be/scri/helpers/KeyboardTest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/helpers/KeyboardTest.kt index 3f248a8e4..c2f9d97c4 100644 --- a/app/src/androidTestKeyboards/kotlin/be/scri/helpers/KeyboardTest.kt +++ b/app/src/androidTestKeyboards/kotlin/be/scri/helpers/KeyboardTest.kt @@ -39,9 +39,6 @@ class KeyboardTest { translateBtn = mockk(relaxed = true) conjugateBtn = mockk(relaxed = true) pluralBtn = mockk(relaxed = true) - every { mockIME.binding.translateBtn } returns translateBtn - every { mockIME.binding.conjugateBtn } returns conjugateBtn - every { mockIME.binding.pluralBtn } returns pluralBtn } @Test diff --git a/app/src/keyboards/java/be/scri/activities/MainActivity.kt b/app/src/keyboards/java/be/scri/activities/MainActivity.kt index 603c010a2..ba4c5c469 100644 --- a/app/src/keyboards/java/be/scri/activities/MainActivity.kt +++ b/app/src/keyboards/java/be/scri/activities/MainActivity.kt @@ -25,7 +25,6 @@ import androidx.navigation.compose.rememberNavController import be.scri.ScribeApp import be.scri.helpers.PreferencesHelper import be.scri.helpers.PreferencesHelper.setLightDarkModePreference -import be.scri.services.EnglishKeyboardIME import be.scri.ui.common.bottombar.BottomBarScreen import be.scri.ui.theme.ScribeTheme @@ -34,8 +33,6 @@ import be.scri.ui.theme.ScribeTheme * Initializes theme settings, navigation, and sets up the main UI using Jetpack Compose. */ class MainActivity : ComponentActivity() { - private var englishKeyboardIME: EnglishKeyboardIME? = null - /** * Initializes the app on launch. Sets the theme based on user preferences, sets up edge-to-edge * layout, and builds the UI using Compose. @@ -48,8 +45,6 @@ class MainActivity : ComponentActivity() { applyNavigationBarStyle(isDark) - englishKeyboardIME = EnglishKeyboardIME() - setContent { val context = LocalContext.current diff --git a/app/src/keyboards/java/be/scri/helpers/AutocompletionHandler.kt b/app/src/keyboards/java/be/scri/helpers/AutocompletionHandler.kt index b8475281b..98c80827d 100644 --- a/app/src/keyboards/java/be/scri/helpers/AutocompletionHandler.kt +++ b/app/src/keyboards/java/be/scri/helpers/AutocompletionHandler.kt @@ -46,10 +46,10 @@ class AutocompletionHandler( return@Runnable } - val completions = ime.getAutocompletions(currentWord, limit = 5) + val result = ime.getAutocompletions(currentWord, limit = 5) - if (completions.isNotEmpty()) { - ime.updateAutocompleteSuggestions(completions) + if (result.completions.isNotEmpty()) { + ime.updateAutocompleteSuggestions(result.completions, result.highlightedSuggestion) } else { ime.clearAutocomplete() } diff --git a/app/src/keyboards/java/be/scri/helpers/BackspaceHandler.kt b/app/src/keyboards/java/be/scri/helpers/BackspaceHandler.kt index e5eb546a1..78b46162c 100644 --- a/app/src/keyboards/java/be/scri/helpers/BackspaceHandler.kt +++ b/app/src/keyboards/java/be/scri/helpers/BackspaceHandler.kt @@ -36,10 +36,9 @@ class BackspaceHandler( isLongPress: Boolean = false, ) { val keyboard = ime.keyboard ?: return - val keyboardView = ime.keyboardView ?: return if (keyboard.mShiftState == SHIFT_ON_ONE_CHAR) { - keyboard.mShiftState = SHIFT_OFF + ime.setShifted(SHIFT_OFF) } if (isCommandBar) { @@ -60,8 +59,7 @@ class BackspaceHandler( // Auto-shift if text is empty if (inputConnection.getTextBeforeCursor(1, 0)?.isEmpty() != false) { - keyboard.mShiftState = SHIFT_ON_ONE_CHAR - keyboardView.invalidateAllKeys() + ime.setShifted(SHIFT_ON_ONE_CHAR) } } } @@ -72,7 +70,7 @@ class BackspaceHandler( private fun handleCommandBarDelete() { val currentTextWithoutCursor = ime.getCommandBarTextWithoutCursor() // If we're already showing the hint, do nothing on delete. - if (currentTextWithoutCursor == ime.currentCommandBarHint) { + if (currentTextWithoutCursor == ime.commandBarHint) { return } @@ -80,8 +78,8 @@ class BackspaceHandler( val newText = currentTextWithoutCursor.dropLast(1) if (newText.isEmpty()) { // All real text has been deleted, so restore the hint. - ime.setCommandBarTextWithCursor(ime.currentCommandBarHint, cursorAtStart = true) - ime.binding.commandBar.setTextColor(ime.commandBarHintColor) + ime.setCommandBarTextWithCursor(ime.commandBarHint, cursorAtStart = true) + // Color update is handled by the ViewModel when text is empty/hint is shown. } else { // There's still text left, so just update it. ime.setCommandBarTextWithCursor(newText) @@ -90,12 +88,12 @@ class BackspaceHandler( // Handle German plural mode shift state. val finalCommandBarText = ime.getCommandBarTextWithoutCursor() - val isEmptyOrAHint = finalCommandBarText.isEmpty() || finalCommandBarText == ime.currentCommandBarHint + val isEmptyOrAHint = finalCommandBarText.isEmpty() || finalCommandBarText == ime.commandBarHint val isGerman = ime.language == "German" val isPluralState = ime.currentState == ScribeState.PLURAL if (isEmptyOrAHint && isGerman && isPluralState) { - ime.keyboard?.mShiftState = SHIFT_ON_ONE_CHAR + ime.setShifted(SHIFT_ON_ONE_CHAR) } } diff --git a/app/src/keyboards/java/be/scri/helpers/FloatingKeyboardHandler.kt b/app/src/keyboards/java/be/scri/helpers/FloatingKeyboardHandler.kt deleted file mode 100644 index 0c237ef4d..000000000 --- a/app/src/keyboards/java/be/scri/helpers/FloatingKeyboardHandler.kt +++ /dev/null @@ -1,605 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package be.scri.helpers - -import android.annotation.SuppressLint -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.GradientDrawable -import android.inputmethodservice.InputMethodService.BACK_DISPOSITION_ADJUST_NOTHING -import android.inputmethodservice.InputMethodService.BACK_DISPOSITION_DEFAULT -import android.os.Handler -import android.os.Looper -import android.view.Gravity -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import androidx.core.content.ContextCompat -import be.scri.R -import be.scri.helpers.PreferencesHelper.getIsDarkModeOrNot -import be.scri.services.GeneralKeyboardIME - -/** - * Manages floating keyboard state, resize handles, touch listeners, and layout transitions - * for [GeneralKeyboardIME]. - * - * @property ime The [GeneralKeyboardIME] instance this handler is associated with. - */ -class FloatingKeyboardHandler( - private val ime: GeneralKeyboardIME, -) { - var isFloatingMode: Boolean = false - private set - - private var lastAppliedFloatingMode: Boolean? = null - - private var initialX = 0f - private var initialY = 0f - private var initialTranslationX = 0f - private var initialTranslationY = 0f - private var maxTranslationX = 0f - private var minTranslationX = 0f - private var minTranslationY = 0f - private var maxTranslationY = 0f - - private val cornerHideHandler = Handler(Looper.getMainLooper()) - private val hideCornersRunnable = Runnable { animateHideCorners() } - - private var initialTouchX = 0f - private var initialTouchY = 0f - private var initialScaleX = 1.0f - private var initialScaleY = 1.0f - private var dragFactorX = 1f - private var dragFactorY = 1f - private var keyboardCenterX = 0f - private var keyboardCenterY = 0f - private var initialDistance = 0f - private var isResizing = false - - fun initFloatingMode() { - isFloatingMode = PreferencesHelper.getIsFloatingModeEnabled(ime, ime.language) - lastAppliedFloatingMode = null - applyFloatingModeState() - } - - fun toggleFloatingMode() { - isFloatingMode = !isFloatingMode - PreferencesHelper.setIsFloatingModeEnabled(ime, ime.language, isFloatingMode) - lastAppliedFloatingMode = null - applyFloatingModeState() - ime.window - ?.window - ?.decorView - ?.requestLayout() - } - - fun disableFloatingMode() { - if (isFloatingMode) { - isFloatingMode = false - PreferencesHelper.setIsFloatingModeEnabled(ime, ime.language, isFloatingMode) - lastAppliedFloatingMode = null - applyFloatingModeState() - ime.window - ?.window - ?.decorView - ?.requestLayout() - } - } - - fun applyFloatingModeState() { - if (!ime.isUiManagerInitialized) return - val card = ime.binding.keyboardCard - val dragBar = ime.binding.floatingDragBar - val density = ime.resources.displayMetrics.density - val root = ime.binding.root - val win = ime.window?.window - - val modeChanged = lastAppliedFloatingMode != isFloatingMode - lastAppliedFloatingMode = isFloatingMode - - val rootWidth = ViewGroup.LayoutParams.MATCH_PARENT - val rootHeight = if (isFloatingMode) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT - val rootParams = root.layoutParams ?: ViewGroup.LayoutParams(rootWidth, rootHeight) - rootParams.width = rootWidth - rootParams.height = rootHeight - root.layoutParams = rootParams - root.minimumHeight = 0 - - val parentViewGroup = root.parent as? ViewGroup - if (parentViewGroup != null) { - val pParams = parentViewGroup.layoutParams - if (pParams != null) { - pParams.width = rootWidth - pParams.height = rootHeight - parentViewGroup.layoutParams = pParams - } - } - - if (isFloatingMode) { - ime.setBackDisposition(BACK_DISPOSITION_ADJUST_NOTHING) - win?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - win?.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) - } else { - ime.setBackDisposition(BACK_DISPOSITION_DEFAULT) - win?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - win?.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) - } - - if (isFloatingMode) { - val params = card.layoutParams - if (params != null) { - params.width = ime.getKeyboardWidth() - card.layoutParams = params - } - - val scaleFactorX = PreferencesHelper.getFloatingScaleX(ime, ime.language) - val scaleFactorY = PreferencesHelper.getFloatingScaleY(ime, ime.language) - card.scaleX = scaleFactorX - card.scaleY = scaleFactorY - card.alpha = 1.0f - - ime.binding.resizeHandleTopLeft.setOnTouchListener(resizeTouchListener) - ime.binding.resizeHandleTopRight.setOnTouchListener(resizeTouchListener) - ime.binding.resizeHandleBottomLeft.setOnTouchListener(resizeTouchListener) - ime.binding.resizeHandleBottomRight.setOnTouchListener(resizeTouchListener) - - if (modeChanged) { - ime.binding.resizeHandleTopLeft.visibility = View.GONE - ime.binding.resizeHandleTopRight.visibility = View.GONE - ime.binding.resizeHandleBottomLeft.visibility = View.GONE - ime.binding.resizeHandleBottomRight.visibility = View.GONE - } - - val isDarkMode = getIsDarkModeOrNot(ime) - val kbBgColorRes = if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color - val kbBgColor = ContextCompat.getColor(ime, kbBgColorRes) - - val floatingBg = - GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = 16f * density - setColor(kbBgColor) - setStroke((1f * density).toInt(), 0x40888888.toInt()) - } - card.background = floatingBg - card.elevation = 8f * density - card.clipToOutline = true - - dragBar.setBackgroundColor(kbBgColor) - val pillColor = if (isDarkMode) 0x4DFFFFFF.toInt() else 0x40000000.toInt() - ime.binding.floatingDragHandle.setColorFilter(pillColor) - - dragBar.visibility = View.VISIBLE - - if (modeChanged) ime.recreateKeyboardPublic() - - card.post { - disableParentClipping(root) - var storedX = PreferencesHelper.getFloatingX(ime, ime.language) - var storedY = PreferencesHelper.getFloatingY(ime, ime.language) - val currentScaleX = PreferencesHelper.getFloatingScaleX(ime, ime.language) - val currentScaleY = PreferencesHelper.getFloatingScaleY(ime, ime.language) - - if (storedY == 0f) storedY = 100f * density - - val screenWidth = ime.resources.displayMetrics.widthPixels - val screenHeight = ime.resources.displayMetrics.heightPixels - val cardWidth = card.width.toFloat() - val cardHeight = card.height.toFloat() - - if (cardWidth > 0f && cardHeight > 0f) { - val maxTranslationX = (screenWidth - cardWidth * currentScaleX) / 2f - val minTranslationX = -maxTranslationX - - val minTranslationY = 0f - val maxTranslationY = screenHeight.toFloat() - cardHeight * currentScaleY - - val targetX = storedX.coerceInSafe(minTranslationX, maxTranslationX) - val targetY = storedY.coerceInSafe(minTranslationY, maxTranslationY) - - updateFloatingViewsPosition(targetX, targetY, currentScaleX, currentScaleY) - - val attr = win?.attributes - if (attr != null) { - attr.gravity = Gravity.TOP or Gravity.START - attr.x = 0 - attr.y = 0 - attr.width = ViewGroup.LayoutParams.MATCH_PARENT - attr.height = ViewGroup.LayoutParams.MATCH_PARENT - win.attributes = attr - } - root.requestLayout() - } - } - } else { - val params = card.layoutParams - if (params != null) { - params.width = ViewGroup.LayoutParams.MATCH_PARENT - card.layoutParams = params - } - - card.scaleX = 1.0f - card.scaleY = 1.0f - card.alpha = 1.0f - - val isDarkMode = getIsDarkModeOrNot(ime) - val kbBgColorRes = if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color - card.background = ColorDrawable(ContextCompat.getColor(ime, kbBgColorRes)) - card.elevation = 0f - card.clipToOutline = false - - dragBar.visibility = View.GONE - - if (modeChanged) ime.recreateKeyboardPublic() - - card.translationX = 0f - card.translationY = 0f - - ime.binding.resizeHandleTopLeft.translationX = 0f - ime.binding.resizeHandleTopLeft.translationY = 0f - ime.binding.resizeHandleTopRight.translationX = 0f - ime.binding.resizeHandleTopRight.translationY = 0f - ime.binding.resizeHandleBottomLeft.translationX = 0f - ime.binding.resizeHandleBottomLeft.translationY = 0f - ime.binding.resizeHandleBottomRight.translationX = 0f - ime.binding.resizeHandleBottomRight.translationY = 0f - - ime.binding.resizeHandleTopLeft.visibility = View.GONE - ime.binding.resizeHandleTopRight.visibility = View.GONE - ime.binding.resizeHandleBottomLeft.visibility = View.GONE - ime.binding.resizeHandleBottomRight.visibility = View.GONE - - val attr = win?.attributes - if (attr != null) { - attr.gravity = Gravity.BOTTOM - attr.x = 0 - attr.y = 0 - attr.width = ViewGroup.LayoutParams.MATCH_PARENT - attr.height = ViewGroup.LayoutParams.WRAP_CONTENT - win.attributes = attr - } - - card.post { - card.translationX = 0f - card.translationY = 0f - root.requestLayout() - } - } - ime.applyNavBarColor() - } - - private fun updateFloatingViewsPosition( - targetX: Float, - targetY: Float, - scaleX: Float, - scaleY: Float, - ) { - val card = ime.binding.keyboardCard - val screenHeight = - ime.resources.displayMetrics.heightPixels - .toFloat() - - val cardWidth = card.width.toFloat() - val cardHeight = card.height.toFloat() - if (cardWidth == 0f || cardHeight == 0f) return - - card.scaleX = scaleX - card.scaleY = scaleY - - val transX = targetX - val transY = (screenHeight - cardHeight * scaleY) / 2f - targetY - - card.translationX = transX - card.translationY = transY - - val scaleOffsetX = scaleX - 1.0f - val scaleOffsetY = scaleY - 1.0f - val halfW = cardWidth / 2f - val halfH = cardHeight / 2f - - ime.binding.resizeHandleTopLeft.translationX = transX - halfW * scaleOffsetX - ime.binding.resizeHandleTopLeft.translationY = transY - halfH * scaleOffsetY - - ime.binding.resizeHandleTopRight.translationX = transX + halfW * scaleOffsetX - ime.binding.resizeHandleTopRight.translationY = transY - halfH * scaleOffsetY - - ime.binding.resizeHandleBottomLeft.translationX = transX - halfW * scaleOffsetX - ime.binding.resizeHandleBottomLeft.translationY = transY + halfH * scaleOffsetY - - ime.binding.resizeHandleBottomRight.translationX = transX + halfW * scaleOffsetX - ime.binding.resizeHandleBottomRight.translationY = transY + halfH * scaleOffsetY - } - - fun disableParentClipping(view: View) { - var p = view.parent - while (p is ViewGroup) { - p.clipChildren = false - p.clipToPadding = false - p = p.parent - } - } - - private fun showCorners() { - cornerHideHandler.removeCallbacks(hideCornersRunnable) - - val corners = - listOf( - ime.binding.resizeHandleTopLeft, - ime.binding.resizeHandleTopRight, - ime.binding.resizeHandleBottomLeft, - ime.binding.resizeHandleBottomRight, - ) - - for (corner in corners) { - corner.animate().cancel() - corner.alpha = 1f - corner.visibility = View.VISIBLE - } - } - - private fun startHideCornersTimer() { - cornerHideHandler.removeCallbacks(hideCornersRunnable) - cornerHideHandler.postDelayed(hideCornersRunnable, 3000) - } - - private fun animateHideCorners() { - val corners = - listOf( - ime.binding.resizeHandleTopLeft, - ime.binding.resizeHandleTopRight, - ime.binding.resizeHandleBottomLeft, - ime.binding.resizeHandleBottomRight, - ) - - for (corner in corners) { - corner - .animate() - .alpha(0f) - .setDuration(300) - .withEndAction { - corner.visibility = View.GONE - }.start() - } - } - - private fun applyScaleAndPosition( - scaleX: Float, - scaleY: Float, - ) { - val card = ime.binding.keyboardCard - val screenHeight = - ime.resources.displayMetrics.heightPixels - .toFloat() - val cardHeight = card.height.toFloat() - - val liveX = card.translationX - val liveTransY = card.translationY - val prevScaleY = card.scaleY - val liveY = (screenHeight - cardHeight * prevScaleY) / 2f - liveTransY - - updateFloatingViewsPosition(liveX, liveY, scaleX, scaleY) - } - - private val resizeTouchListener = - View.OnTouchListener { view, event -> - if (!isFloatingMode) return@OnTouchListener false - - when (event.action) { - MotionEvent.ACTION_DOWN -> { - isResizing = true - showCorners() - - initialTouchX = event.rawX - initialTouchY = event.rawY - initialScaleX = PreferencesHelper.getFloatingScaleX(ime, ime.language) - initialScaleY = PreferencesHelper.getFloatingScaleY(ime, ime.language) - - val viewId = view.id - dragFactorX = - when (viewId) { - R.id.resize_handle_top_left -> -1f - R.id.resize_handle_bottom_left -> -1f - R.id.resize_handle_top_right -> 1f - R.id.resize_handle_bottom_right -> 1f - else -> 1f - } - dragFactorY = - when (viewId) { - R.id.resize_handle_top_left -> -1f - R.id.resize_handle_top_right -> -1f - R.id.resize_handle_bottom_left -> 1f - R.id.resize_handle_bottom_right -> 1f - else -> 1f - } - - val card = ime.binding.keyboardCard - card.animate().cancel() - card.alpha = 0.7f - - val density = ime.resources.displayMetrics.density - val activeColor = ContextCompat.getColor(ime, R.color.theme_scribe_blue) - (card.background as? GradientDrawable)?.setStroke((2.5f * density).toInt(), activeColor) - - val location = IntArray(2) - card.getLocationOnScreen(location) - - keyboardCenterX = location[0] + card.width / 2f - keyboardCenterY = location[1] + card.height / 2f - - initialDistance = - Math - .hypot( - (event.rawX - keyboardCenterX).toDouble(), - (event.rawY - keyboardCenterY).toDouble(), - ).toFloat() - - true - } - MotionEvent.ACTION_MOVE -> { - if (!isResizing) return@OnTouchListener false - - val dx = event.rawX - initialTouchX - val dy = event.rawY - initialTouchY - - val card = ime.binding.keyboardCard - val cardWidth = card.width.toFloat() - val cardHeight = card.height.toFloat() - - if (cardWidth > 0 && cardHeight > 0) { - var targetScaleX = initialScaleX + (dragFactorX * dx) / cardWidth - var targetScaleY = initialScaleY + (dragFactorY * dy) / cardHeight - - targetScaleX = targetScaleX.coerceIn(0.6f, 1.5f) - targetScaleY = targetScaleY.coerceIn(0.6f, 1.5f) - - applyScaleAndPosition(targetScaleX, targetScaleY) - } - true - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - isResizing = false - startHideCornersTimer() - - val card = ime.binding.keyboardCard - val finalScaleX = card.scaleX - val finalScaleY = card.scaleY - PreferencesHelper.setFloatingScaleX(ime, ime.language, finalScaleX) - PreferencesHelper.setFloatingScaleY(ime, ime.language, finalScaleY) - - val screenHeight = - ime.resources.displayMetrics.heightPixels - .toFloat() - val cardHeight = card.height.toFloat() - val liveY = (screenHeight - cardHeight * finalScaleY) / 2f - card.translationY - PreferencesHelper.setFloatingX(ime, ime.language, card.translationX) - PreferencesHelper.setFloatingY(ime, ime.language, liveY) - - applyFloatingModeState() - card - .animate() - .alpha(1.0f) - .setDuration(200) - .start() - true - } - else -> false - } - } - - @SuppressLint("ClickableViewAccessibility") - fun setupFloatingDragListener() { - if (!ime.isUiManagerInitialized) return - - ime.binding.floatingDragHandle.setOnTouchListener { _, event -> - if (!isFloatingMode) return@setOnTouchListener false - - when (event.action) { - MotionEvent.ACTION_DOWN -> { - initialX = event.rawX - initialY = event.rawY - - val card = ime.binding.keyboardCard - card.animate().cancel() - card.alpha = 0.7f - - val density = ime.resources.displayMetrics.density - val activeColor = ContextCompat.getColor(ime, R.color.theme_scribe_blue) - (card.background as? GradientDrawable)?.setStroke((2.5f * density).toInt(), activeColor) - - val displayMetrics = ime.resources.displayMetrics - val screenWidth = displayMetrics.widthPixels - val screenHeight = displayMetrics.heightPixels - val cardWidth = card.width.toFloat() - val cardHeight = card.height.toFloat() - val scaleFactorX = PreferencesHelper.getFloatingScaleX(ime, ime.language) - val scaleFactorY = PreferencesHelper.getFloatingScaleY(ime, ime.language) - - maxTranslationX = (screenWidth - cardWidth * scaleFactorX) / 2f - minTranslationX = -maxTranslationX - - minTranslationY = 0f - maxTranslationY = screenHeight.toFloat() - cardHeight * scaleFactorY - - initialTranslationX = PreferencesHelper.getFloatingX(ime, ime.language).coerceInSafe(minTranslationX, maxTranslationX) - initialTranslationY = PreferencesHelper.getFloatingY(ime, ime.language).coerceInSafe(minTranslationY, maxTranslationY) - - showCorners() - true - } - MotionEvent.ACTION_MOVE -> { - val deltaX = event.rawX - initialX - val deltaY = event.rawY - initialY - - var targetX = initialTranslationX + deltaX - var targetY = initialTranslationY - deltaY - - targetX = targetX.coerceInSafe(minTranslationX, maxTranslationX) - targetY = targetY.coerceInSafe(minTranslationY, maxTranslationY) - - val scaleFactorX = PreferencesHelper.getFloatingScaleX(ime, ime.language) - val scaleFactorY = PreferencesHelper.getFloatingScaleY(ime, ime.language) - updateFloatingViewsPosition(targetX, targetY, scaleFactorX, scaleFactorY) - - val card = ime.binding.keyboardCard - val density = ime.resources.displayMetrics.density - val isNearBottom = targetY < 60f * density - if (isNearBottom) { - val dockColor = ContextCompat.getColor(ime, R.color.theme_scribe_blue) - (card.background as? GradientDrawable)?.setStroke((4.0f * density).toInt(), dockColor) - card.alpha = 0.85f - } else { - val activeColor = ContextCompat.getColor(ime, R.color.theme_scribe_blue) - (card.background as? GradientDrawable)?.setStroke((2.5f * density).toInt(), activeColor) - card.alpha = 0.7f - } - - true - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - val deltaX = event.rawX - initialX - val deltaY = event.rawY - initialY - var finalTargetX = initialTranslationX + deltaX - var finalTargetY = initialTranslationY - deltaY - finalTargetX = finalTargetX.coerceInSafe(minTranslationX, maxTranslationX) - finalTargetY = finalTargetY.coerceInSafe(minTranslationY, maxTranslationY) - - val scaleFactorX = PreferencesHelper.getFloatingScaleX(ime, ime.language) - val scaleFactorY = PreferencesHelper.getFloatingScaleY(ime, ime.language) - val density = ime.resources.displayMetrics.density - val isNearBottom = finalTargetY < 60f * density - - val card = ime.binding.keyboardCard - - if (isNearBottom) { - disableFloatingMode() - } else { - updateFloatingViewsPosition(finalTargetX, finalTargetY, scaleFactorX, scaleFactorY) - PreferencesHelper.setFloatingX(ime, ime.language, finalTargetX) - PreferencesHelper.setFloatingY(ime, ime.language, finalTargetY) - applyFloatingModeState() - } - - card - .animate() - .alpha(1.0f) - .setDuration(200) - .start() - ime.binding.root.requestLayout() - startHideCornersTimer() - true - } - else -> false - } - } - } -} - -private fun Float.coerceInSafe( - minimumValue: Float, - maximumValue: Float, -): Float = - if (minimumValue > maximumValue) { - minimumValue - } else { - this.coerceIn(minimumValue, maximumValue) - } diff --git a/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt b/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt index 25663f03a..5f83d3e56 100644 --- a/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt +++ b/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt @@ -51,11 +51,11 @@ class KeyHandler( resetShiftIfNeeded(code) if (code != KeyboardBase.KEYCODE_SHIFT && code != KeyboardBase.KEYCODE_MODE_CHANGE) { - ime.hideClipboardSuggestionChip() + // ime.hideClipboardSuggestionChip() } val previousWasLastKeySpace = wasLastKeySpace - if (code != KeyboardBase.KEYCODE_SPACE && code != KeyboardBase.KEYCODE_ENTER) { + if (code != KeyboardBase.KEYCODE_SPACE && code != KeyboardBase.KEYCODE_ENTER && !ime.emojiColonModeOn) { suggestionHandler.clearLinguisticSuggestions() } @@ -157,9 +157,17 @@ class KeyHandler( */ private fun handleSpaceKeyPress(previousWasLastKeySpace: Boolean): Boolean { wasLastKeySpace = spaceKeyProcessor.processKeycodeSpace(previousWasLastKeySpace) + if (ime.emojiColonModeOn) { + exitEmojiColonMode() + } return false } + private fun exitEmojiColonMode() { + ime.emojiColonModeOn = false + ime.clearAutocomplete() + } + /** * Checks if the IME is in a valid state to process key events. * A valid state requires a non-null keyboard instance and an active input connection. @@ -205,9 +213,7 @@ class KeyHandler( } else { KeyboardBase.SHIFT_OFF } - if (kb.setShifted(newState)) { - ime.keyboardView?.invalidateAllKeys() - } + kb.setShifted(newState) } } @@ -217,11 +223,19 @@ class KeyHandler( */ private fun handleDeleteKey() { + val charToDelete = ime.currentInputConnection?.getTextBeforeCursor(1, 0) ime.handleDelete(ime.isDeleteRepeating()) // pass the actual repeating status if (ime.currentState == ScribeState.IDLE) { + val deletedChar = charToDelete?.takeIf { it.isNotEmpty() }?.last() + if (deletedChar == ':' && ime.emojiColonModeOn) { + exitEmojiColonMode() + } + val currentWord = ime.getLastWordBeforeCursor() - autocompletionHandler.processAutocomplete(currentWord) + if (!ime.emojiColonModeOn) { + autocompletionHandler.processAutocomplete(currentWord) + } suggestionHandler.processEmojiSuggestions(currentWord) } } @@ -244,8 +258,7 @@ class KeyHandler( * and then invalidates the keyboard view to reflect the change. */ private fun handleShiftKey() { - ime.handleKeyboardLetters(ime.keyboardMode, ime.keyboardView) - ime.keyboardView?.invalidateAllKeys() + ime.handleKeyboardLetters(ime.keyboardMode) } /** @@ -261,8 +274,12 @@ class KeyHandler( * It delegates the logic to the IME and clears any active suggestions. */ private fun handleModeChangeKey() { - ime.handleModeChange(ime.keyboardMode, ime.keyboardView, ime) - suggestionHandler.clearAllSuggestionsAndHideButtonUI() + ime.handleModeChange(ime.keyboardMode, ime) + if (ime.emojiColonModeOn) { + suggestionHandler.processEmojiSuggestions(ime.getLastWordBeforeCursor()) + } else { + suggestionHandler.clearAllSuggestionsAndHideButtonUI() + } } /** @@ -328,7 +345,6 @@ class KeyHandler( editor.putInt("conjugate_index", currentValue) editor.apply() - ime.updateUI() Log.i(TAG, "New conjugate_index: $currentValue") } @@ -407,8 +423,19 @@ class KeyHandler( } if (ime.currentState == ScribeState.IDLE) { + if (code == ':'.code && ime.getLastWordBeforeCursor() == ":") { + ime.emojiColonModeOn = true + ime.autoSuggestEmojis = EmojiUtils.COMMON_EMOJIS.toMutableList() + ime.updateEmojiSuggestion(true, ime.autoSuggestEmojis) + } + val currentWord = ime.getLastWordBeforeCursor() - autocompletionHandler.processAutocomplete(currentWord) + if (ime.emojiColonModeOn && currentWord?.startsWith(":") != true) { + exitEmojiColonMode() + } + if (!ime.emojiColonModeOn) { + autocompletionHandler.processAutocomplete(currentWord) + } suggestionHandler.processEmojiSuggestions(currentWord) } else if (isCommandBarActive) { suggestionHandler.clearAllSuggestionsAndHideButtonUI() diff --git a/app/src/keyboards/java/be/scri/helpers/SpaceKeyProcessor.kt b/app/src/keyboards/java/be/scri/helpers/SpaceKeyProcessor.kt index 6033be253..e43795372 100644 --- a/app/src/keyboards/java/be/scri/helpers/SpaceKeyProcessor.kt +++ b/app/src/keyboards/java/be/scri/helpers/SpaceKeyProcessor.kt @@ -61,6 +61,13 @@ class SpaceKeyProcessor( * @param wasLastKeySpace true if the previous key pressed was a space. */ private fun handleSpaceOutsideCommandBar(wasLastKeySpace: Boolean) { + if (ime.tryInsertHighlightedAutocompleteSuggestion()) { + val insertedWord = ime.getLastWordBeforeCursor() + suggestionHandler.processLinguisticSuggestions(insertedWord) + suggestionHandler.processWordSuggestions(insertedWord) + return + } + val periodOnDoubleTapEnabled = PreferencesHelper.getEnablePeriodOnSpaceBarDoubleTap(context = ime, ime.language) val ic = ime.currentInputConnection ?: return val wordBeforeSpace = ime.getLastWordBeforeCursor() @@ -96,8 +103,7 @@ class SpaceKeyProcessor( } if (shouldEnableAutoCapitalization) { - ime.keyboard?.mShiftState = SHIFT_ON_ONE_CHAR - ime.keyboardView?.invalidateAllKeys() + ime.setShifted(SHIFT_ON_ONE_CHAR) } suggestionHandler.processLinguisticSuggestions(wordBeforeSpace) diff --git a/app/src/keyboards/java/be/scri/helpers/SuggestionHandler.kt b/app/src/keyboards/java/be/scri/helpers/SuggestionHandler.kt index 3b836ac59..781b31caa 100644 --- a/app/src/keyboards/java/be/scri/helpers/SuggestionHandler.kt +++ b/app/src/keyboards/java/be/scri/helpers/SuggestionHandler.kt @@ -147,20 +147,32 @@ class SuggestionHandler( } val emojis = - if (ime.emojiAutoSuggestionEnabled) { - ime.findEmojisForLastWord(ime.emojiKeywords, currentWord) - } else { - null + when { + ime.emojiColonModeOn -> { + val keyword = currentWord.removePrefix(":") + if (keyword.isEmpty()) { + EmojiUtils.COMMON_EMOJIS.toMutableList() + } else { + ime.findEmojisForPrefix(ime.emojiKeywords, keyword) + } + } + ime.emojiAutoSuggestionEnabled -> ime.findEmojisForLastWord(ime.emojiKeywords, currentWord) + else -> null } val hasEmojiSuggestion = !emojis.isNullOrEmpty() - if (hasEmojiSuggestion) { - ime.autoSuggestEmojis = emojis - ime.updateEmojiSuggestion(true, emojis) - ime.updateButtonVisibility(true) - } else { - ime.updateButtonVisibility(false) + when { + hasEmojiSuggestion -> { + ime.autoSuggestEmojis = emojis + ime.updateEmojiSuggestion(true, emojis) + ime.updateButtonVisibility(true) + } + ime.emojiColonModeOn -> { + ime.autoSuggestEmojis = mutableListOf() + ime.updateEmojiSuggestion(true, mutableListOf()) + } + else -> ime.updateButtonVisibility(false) } } @@ -189,6 +201,7 @@ class SuggestionHandler( fun clearAllSuggestionsAndHideButtonUI() { emojiSuggestionRunnable?.let { handler.removeCallbacks(it) } linguisticSuggestionRunnable?.let { handler.removeCallbacks(it) } + wordSuggestionRunnable?.let { handler.removeCallbacks(it) } if (ime.currentState != ScribeState.SELECT_COMMAND) { ime.disableAutoSuggest() diff --git a/app/src/keyboards/java/be/scri/helpers/clipboard/ClipboardHandler.kt b/app/src/keyboards/java/be/scri/helpers/clipboard/ClipboardHandler.kt deleted file mode 100644 index 32af7feac..000000000 --- a/app/src/keyboards/java/be/scri/helpers/clipboard/ClipboardHandler.kt +++ /dev/null @@ -1,126 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package be.scri.helpers.clipboard - -import android.view.View -import androidx.recyclerview.widget.GridLayoutManager -import be.scri.models.ScribeState -import be.scri.services.GeneralKeyboardIME -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -/** - * Manages in-keyboard clipboard monitoring, suggestion chips, and history panel operations - * for [GeneralKeyboardIME]. - * - * @property ime The [GeneralKeyboardIME] instance this handler is associated with. - */ -class ClipboardHandler( - private val ime: GeneralKeyboardIME, -) { - var latestClipText: String? = null - internal set - var hasNewClip: Boolean = false - internal set - - private lateinit var clipboardMonitor: ClipboardMonitor - private var clipboardAdapter: ClipboardAdapter? = null - private val clipboardRepository by lazy { ClipboardRepository(ime) } - - fun initClipboardMonitor() { - clipboardMonitor = - ClipboardMonitor(ime) { text -> - latestClipText = text - hasNewClip = true - if (ime.currentState == ScribeState.IDLE && ime.isUiManagerInitialized) { - ime.uiManager.showClipboardSuggestionChip(text) - } - } - } - - fun startMonitoring() { - if (this::clipboardMonitor.isInitialized) { - clipboardMonitor.startMonitoring() - } - } - - fun stopMonitoring() { - if (this::clipboardMonitor.isInitialized) { - clipboardMonitor.stopMonitoring() - } - } - - fun onClipboardSuggestionClicked() { - latestClipText?.let { text -> - ime.currentInputConnection?.commitText(text, 1) - } - hideClipboardSuggestionChip() - } - - fun hideClipboardSuggestionChip() { - hasNewClip = false - latestClipText = null - if (ime.isUiManagerInitialized) { - ime.uiManager.hideClipboardSuggestionChip() - } - } - - fun openClipboardPanel() { - if (!ime.isUiManagerInitialized) return - ime.uiManager.showClipboardPanel() - - val recyclerView = ime.binding.clipboardItemsList - val emptyText = ime.binding.clipboardEmptyText - - clipboardAdapter = - ClipboardAdapter( - items = emptyList(), - onItemClick = { item -> - ime.currentInputConnection?.commitText(item.text, 1) - closeClipboardPanel() - }, - onItemDelete = { item -> - CoroutineScope(Dispatchers.Main).launch { - clipboardRepository.deleteItem(item.id) - refreshClipboardPanel() - } - }, - onItemPinToggle = { item -> - CoroutineScope(Dispatchers.Main).launch { - clipboardRepository.togglePin(item.id, item.isPinned) - refreshClipboardPanel() - } - }, - ) - recyclerView.adapter = clipboardAdapter - recyclerView.layoutManager = GridLayoutManager(ime, 2) - - ime.binding.clipboardPanelClose.setOnClickListener { closeClipboardPanel() } - ime.binding.clipboardClearAll.setOnClickListener { - CoroutineScope(Dispatchers.Main).launch { - clipboardRepository.clearAll() - refreshClipboardPanel() - } - } - - CoroutineScope(Dispatchers.Main).launch { - val items = clipboardRepository.getAllItems() - clipboardAdapter?.updateItems(items) - emptyText.visibility = if (items.isEmpty()) View.VISIBLE else View.GONE - recyclerView.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE - } - } - - fun closeClipboardPanel() { - if (!ime.isUiManagerInitialized) return - ime.uiManager.hideClipboardPanel() - } - - private suspend fun refreshClipboardPanel() { - val items = clipboardRepository.getAllItems() - clipboardAdapter?.updateItems(items) - ime.binding.clipboardEmptyText.visibility = if (items.isEmpty()) View.VISIBLE else View.GONE - ime.binding.clipboardItemsList.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE - } -} diff --git a/app/src/keyboards/java/be/scri/helpers/ui/KeyboardThemeManager.kt b/app/src/keyboards/java/be/scri/helpers/ui/KeyboardThemeManager.kt deleted file mode 100644 index 788ef0f94..000000000 --- a/app/src/keyboards/java/be/scri/helpers/ui/KeyboardThemeManager.kt +++ /dev/null @@ -1,237 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package be.scri.helpers.ui - -import android.content.Context -import android.content.res.ColorStateList -import android.graphics.Color -import android.graphics.drawable.GradientDrawable -import android.graphics.drawable.LayerDrawable -import android.graphics.drawable.RippleDrawable -import android.inputmethodservice.InputMethodService -import android.os.Build -import android.view.View -import android.view.Window -import android.widget.Button -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.core.graphics.ColorUtils -import androidx.core.graphics.toColorInt -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import be.scri.R -import be.scri.helpers.PreferencesHelper.getIsDarkModeOrNot - -/** - * Manages UI theme color resolutions, gradient/ripple drawables creation, - * navigation bar colors, and system bar insets for the Scribe keyboard. - */ -class KeyboardThemeManager { - /** - * Calculates whether a given ARGB color is considered light based on relative luminance. - */ - fun isLightColor(color: Int): Boolean { - val red = (color shr 16) and 0xFF - val green = (color shr 8) and 0xFF - val blue = color and 0xFF - val darkness = 1 - (0.299 * red + 0.587 * green + 0.114 * blue) / 255 - return darkness < 0.5 - } - - /** - * Applies navigation bar color, window decor insets, light/dark appearance flags, and system bar behaviors. - */ - fun applyNavBarColor( - service: InputMethodService, - window: Window?, - isFloatingMode: Boolean, - uiManager: KeyboardUIManager?, - ) { - val targetWindow = window ?: return - targetWindow.decorView.post { - val context = service.applicationContext - val isDarkMode = getIsDarkModeOrNot(context) - val colorRes = if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color - val color = ContextCompat.getColor(service, colorRes) - - WindowCompat.setDecorFitsSystemWindows(targetWindow, false) - if (Build.VERSION.SDK_INT < 35) { - @Suppress("DEPRECATION") - targetWindow.navigationBarColor = Color.TRANSPARENT - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - targetWindow.isNavigationBarContrastEnforced = false - } - - if (isFloatingMode) { - targetWindow.decorView.setBackgroundColor(Color.TRANSPARENT) - } else { - targetWindow.decorView.setBackgroundColor(color) - } - val insetsController = WindowCompat.getInsetsController(targetWindow, targetWindow.decorView) - insetsController.isAppearanceLightNavigationBars = isLightColor(color) - - if (isFloatingMode) { - insetsController.hide(WindowInsetsCompat.Type.navigationBars()) - insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - @Suppress("DEPRECATION") - targetWindow.decorView.systemUiVisibility = ( - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - ) - } else { - insetsController.show(WindowInsetsCompat.Type.navigationBars()) - @Suppress("DEPRECATION") - targetWindow.decorView.systemUiVisibility = 0 - } - - if (uiManager != null) { - if (isFloatingMode) { - uiManager.binding.root.setBackgroundColor(Color.TRANSPARENT) - val kbBgColor = ContextCompat.getColor(service, if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color) - uiManager.binding.floatingDragBar.setBackgroundColor(kbBgColor) - val pillColor = if (isDarkMode) 0x4DFFFFFF.toInt() else 0x40000000.toInt() - uiManager.binding.floatingDragHandle.setColorFilter(pillColor) - } else { - uiManager.binding.root.setBackgroundColor(color) - } - - ViewCompat.setOnApplyWindowInsetsListener(uiManager.binding.root) { view, insets -> - val insetTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() - val navBarHeight = insets.getInsets(insetTypes).bottom - val paddingBottom = if (isFloatingMode) 0 else navBarHeight - view.setPadding(0, 0, 0, paddingBottom) - insets - } - - uiManager.binding.root.post { - ViewCompat.requestApplyInsets(uiManager.binding.root) - } - } - } - } - - /** - * Applies text colors, icon tints, ripple backgrounds, and shadow colors to empty state banner views. - */ - fun applyBannerTheme( - context: Context, - banner: TextView, - bannerContainer: View, - isDarkMode: Boolean = getIsDarkModeOrNot(context), - density: Float = context.resources.displayMetrics.density, - ) { - val bannerColor = if (isDarkMode) R.color.dark_tutorial_button_color else R.color.light_tutorial_button_color - val bannerTextColor = if (isDarkMode) R.color.dark_button_outline_color else R.color.light_text_color - banner.setTextColor(ContextCompat.getColor(context, bannerTextColor)) - - banner.post { - val iconColor = ContextCompat.getColor(context, bannerTextColor) - banner.compoundDrawables.forEach { drawable -> - drawable?.setTint(iconColor) - } - } - - val border = GradientDrawable() - border.cornerRadius = 12f * density - border.setColor(ContextCompat.getColor(context, bannerColor)) - - if (isDarkMode) { - border.setStroke( - (1.5f * density).toInt(), - ContextCompat.getColor(context, bannerTextColor), - ) - } - - val rippleColor = - ColorUtils.setAlphaComponent( - ContextCompat.getColor(context, bannerTextColor), - 51, - ) - val rippleDrawable = RippleDrawable(ColorStateList.valueOf(rippleColor), border, null) - - bannerContainer.background = rippleDrawable - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - bannerContainer.outlineAmbientShadowColor = Color.TRANSPARENT - bannerContainer.outlineSpotShadowColor = Color.TRANSPARENT - } - } - - /** - * Applies a specific style to a suggestion button, including text, color, and a custom background. - */ - fun applyInformativeSuggestionStyle( - context: Context, - button: Button, - colorRes: Int, - text: String, - backgroundRes: Int, - ) { - button.text = text - button.setTextColor(ContextCompat.getColor(context, R.color.white)) - button.isClickable = false - button.setOnClickListener(null) - - val background = ContextCompat.getDrawable(context, backgroundRes)?.mutate() - - if (background is RippleDrawable) { - val contentDrawable = background.getDrawable(0) - - if (contentDrawable is LayerDrawable) { - val shapeDrawable = - contentDrawable.findDrawableByLayerId( - R.id.button_background_shape, - ) as? GradientDrawable - - shapeDrawable?.setColor( - ContextCompat.getColor( - context, - colorRes, - ), - ) - } - } - button.background = background - } - - /** - * Applies rounded background, tint, and text color to a single suggestion button based on color resource and dark mode. - */ - fun applySingleSuggestionStyle( - context: Context, - button: Button, - colorRes: Int, - buttonText: String, - textSizeSp: Float? = null, - ) { - button.visibility = View.VISIBLE - button.text = buttonText - if (textSizeSp != null) { - button.textSize = textSizeSp - } - button.isClickable = false - button.setOnClickListener(null) - - if (colorRes != R.color.transparent) { - button.background = ContextCompat.getDrawable(context, R.drawable.button_background_rounded) - button.backgroundTintList = ContextCompat.getColorStateList(context, colorRes) - button.setTextColor(ContextCompat.getColor(context, R.color.white)) - } else { - button.background = null - val isUserDarkMode = getIsDarkModeOrNot(context) - button.backgroundTintList = ContextCompat.getColorStateList(context, R.color.transparent) - button.setTextColor(ContextCompat.getColor(context, if (isUserDarkMode) R.color.white else android.R.color.black)) - } - } - - /** - * Resolves text color for standard word autocompletion suggestion buttons based on dark/light mode preference. - */ - fun getSuggestionTextColor(context: Context): Int { - val isDarkMode = getIsDarkModeOrNot(context) - return if (isDarkMode) Color.WHITE else "#1E1E1E".toColorInt() - } -} diff --git a/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt b/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt deleted file mode 100644 index 563a16565..000000000 --- a/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt +++ /dev/null @@ -1,1246 +0,0 @@ -// 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.ColorStateList -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 androidx.core.view.updateLayoutParams -import androidx.recyclerview.widget.GridLayoutManager -import be.scri.R -import be.scri.R.color.white -import be.scri.databinding.InputMethodViewBinding -import be.scri.helpers.AutoGridLayoutManager -import be.scri.helpers.EMOJI_SPEC_FILE_PATH -import be.scri.helpers.EmojiAdapter -import be.scri.helpers.EmojiData -import be.scri.helpers.KeyboardBase -import be.scri.helpers.KeyboardLanguageMappingConstants.conjugatePlaceholder -import be.scri.helpers.KeyboardLanguageMappingConstants.emojiCategoryHeaders -import be.scri.helpers.KeyboardLanguageMappingConstants.pluralPlaceholder -import be.scri.helpers.KeyboardLanguageMappingConstants.translatePlaceholder -import be.scri.helpers.LanguageMappingConstants.getLanguageAlias -import be.scri.helpers.PreferencesHelper -import be.scri.helpers.PreferencesHelper.getIsDarkModeOrNot -import be.scri.helpers.english.ENInterfaceVariables.ALREADY_PLURAL_MSG -import be.scri.helpers.getCategoryIconRes -import be.scri.helpers.getRecentEmojis -import be.scri.helpers.parseRawEmojiSpecsFile -import be.scri.models.ScribeState -import be.scri.services.GeneralKeyboardIME -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 onFloatClicked() - - fun isFloatingModeActive(): Boolean - - fun onEmojiSelected(emoji: String) - - fun onSuggestionClicked(suggestion: String) - - fun getKeyboardLayoutXML(): Int - - fun getCurrentKeyboardLayoutXML(): Int - - fun getCurrentEnterKeyType(): Int - - fun commitText(text: String) - - fun onKeyboardActionListener(): KeyboardView.OnKeyboardActionListener - - fun processLinguisticSuggestions(word: String) - - fun isNumericKeyboardActive(): Boolean - - fun getKeyboardWidth(): Int - - fun onClipboardSuggestionClicked() - } - - var keyboardView: KeyboardView = binding.keyboardView - var keyboard: KeyboardBase? = null - - // UI Elements - var pluralBtn: Button? = binding.pluralBtn - var floatBtn: Button? = null - var separatorFloat: View? = null - 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 var currentInvalidTexts: Array = HintUtils.getInvalidTextsWikidata("English") - - init { - setupClickListeners() - } - - private fun setupClickListeners() { - binding.scribeKeyOptions.setOnClickListener { - hideClipboardSuggestionChip() - listener.onScribeKeyOptionsClicked() - } - binding.scribeKeyToolbar.setOnClickListener { - hideClipboardSuggestionChip() - listener.onScribeKeyToolbarClicked() - } - - binding.translateBtn.setOnClickListener { listener.onTranslateClicked() } - binding.conjugateBtn.setOnClickListener { listener.onConjugateClicked() } - binding.pluralBtn.setOnClickListener { listener.onPluralClicked() } - - binding.scribeKeyClose.setOnClickListener { listener.onCloseClicked() } - - binding.clipboardSuggestionChip.setOnClickListener { listener.onClipboardSuggestionClicked() } - - // 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?, - invalidCommandSource: ScribeState = ScribeState.IDLE, - ) { - val isUserDarkMode = getIsDarkModeOrNot(context) - - when (currentState) { - ScribeState.IDLE -> - setupIdleView( - language, - emojiAutoSuggestionEnabled, - autoSuggestEmojis, - ) - ScribeState.SELECT_COMMAND -> setupSelectCommandView(language) - ScribeState.INVALID -> setupInvalidView(language, invalidCommandSource) - 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 = if (listener.isNumericKeyboardActive()) View.GONE else 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.foreground = 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.invalidInfoBar.visibility = View.GONE - currentPage = 0 - - binding.scribeKeyOptions.foreground = AppCompatResources.getDrawable(context, R.drawable.ic_scribe_icon_vector) - - val keyboardXml = listener.getCurrentKeyboardLayoutXML() - initializeKeyboard(keyboardXml) - if (keyboardXml == R.xml.keys_symbols) { - setupCurrencySymbol(language) - } - - 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 = if (listener.isNumericKeyboardActive()) View.GONE else 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 - button.isAllCaps = false - } - - val isFloating = listener.isFloatingModeActive() - if (isFloating) { - binding.translateBtn.text = "" - binding.conjugateBtn.text = "" - binding.pluralBtn.text = "" - - binding.translateBtn.foreground = ContextCompat.getDrawable(context, R.drawable.ic_translate_command) - binding.conjugateBtn.foreground = ContextCompat.getDrawable(context, R.drawable.ic_conjugate_command) - binding.pluralBtn.foreground = ContextCompat.getDrawable(context, R.drawable.ic_plural_command) - - binding.translateBtn.foregroundGravity = android.view.Gravity.CENTER - binding.conjugateBtn.foregroundGravity = android.view.Gravity.CENTER - binding.pluralBtn.foregroundGravity = android.view.Gravity.CENTER - } else { - binding.translateBtn.text = translatePlaceholder[langAlias] ?: "Translate" - binding.conjugateBtn.text = conjugatePlaceholder[langAlias] ?: "Conjugate" - binding.pluralBtn.text = pluralPlaceholder[langAlias] ?: "Plural" - - binding.translateBtn.foreground = null - binding.conjugateBtn.foreground = null - binding.pluralBtn.foreground = null - } - - 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 - - binding.conjugateGridContainer.setBackgroundColor( - ContextCompat.getColor( - context, - if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color, - ), - ) - - 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.backgroundTintList = - ContextCompat.getColorStateList( - context, - if (isDarkMode) R.color.dark_key_color else R.color.light_key_color, - ) - btn.setTextColor(if (isDarkMode) Color.WHITE else Color.BLACK) - 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 isDarkMode = getIsDarkModeOrNot(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) - if (arrowBtn != null) { - arrowBtn.background = ContextCompat.getDrawable(context, R.drawable.button_background_rounded) - arrowBtn.backgroundTintList = - ContextCompat.getColorStateList( - context, - if (isDarkMode) R.color.dark_key_color else R.color.light_key_color, - ) - val iconTint = if (isDarkMode) R.color.white else R.color.light_key_text_color - arrowBtn.compoundDrawableTintList = ContextCompat.getColorStateList(context, iconTint) - arrowBtn.setTextColor(if (isDarkMode) Color.WHITE else Color.BLACK) - 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. - * Shows Wikidata info for conjugate/plural commands, and Wiktionary info for the translate command. - */ - @SuppressLint("SetTextI18n") - private fun setupInvalidView( - language: String, - invalidCommandSource: ScribeState, - ) { - 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(), - ) - - val isWikidata = invalidCommandSource != ScribeState.TRANSLATE - val invalidMsg = - if (isWikidata) { - HintUtils.getInvalidHintWikidata(language) - } else { - HintUtils.getInvalidHintWiktionary(language) - } - currentInvalidTexts = - if (isWikidata) { - HintUtils.getInvalidTextsWikidata(language) - } else { - HintUtils.getInvalidTextsWiktionary(language) - } - - binding.ivInfo.visibility = View.VISIBLE - binding.promptText.text = "$invalidMsg: " - 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() - val width = listener.getKeyboardWidth() - keyboard = KeyboardBase(context, xmlId, enterKeyType, width) - 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 || - context.resources.configuration.smallestScreenWidthDp >= GeneralKeyboardIME.SMALLEST_SCREEN_WIDTH_TABLET - - 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 - - binding.translateBtn.foreground = null - binding.conjugateBtn.foreground = null - pluralBtn?.foreground = null - } - - /** - * 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 - } - - /** - * Displays the emoji palette and hides the keyboard view. - * Loads emojis from the emoji spec file on a background thread and populates the grid. - */ - fun showEmojiPalette(language: String) { - binding.keyboardView.post { - val keyboardHeight = binding.keyboardView.measuredHeight - val toolbarHeight = - binding.commandOptionsBar.measuredHeight.takeIf { it > 0 } - ?: context.resources.getDimensionPixelSize(R.dimen.toolbar_height) - - binding.emojiPaletteHolder.updateLayoutParams { - height = keyboardHeight + toolbarHeight - } - binding.emojiPaletteHolder.requestLayout() - } - - binding.emojiPaletteHolder.visibility = View.VISIBLE - - binding.keyboardView.visibility = View.GONE - binding.commandOptionsBar.visibility = View.GONE - - val isDarkMode = getIsDarkModeOrNot(context) - - binding.emojiPaletteClose.setOnClickListener { - hideEmojiPalette() - } - binding.emojiPaletteClose.setColorFilter(if (isDarkMode) Color.WHITE else Color.BLACK) - - binding.emojiPaletteModeChange.setOnClickListener { - hideEmojiPalette() - } - binding.emojiPaletteModeChange.text = "ABC" - binding.emojiPaletteModeChange.setTextColor(if (isDarkMode) Color.WHITE else Color.BLACK) - - binding.emojiPaletteBackspace.setOnClickListener { - listener.onKeyboardActionListener().onKey(KeyboardBase.KEYCODE_DELETE) - } - binding.emojiPaletteBackspace.setColorFilter(if (isDarkMode) Color.WHITE else Color.BLACK) - val keySelector = if (isDarkMode) R.drawable.keyboard_key_selector_dark else R.drawable.keyboard_key_selector - binding.emojiPaletteModeChange.setBackgroundResource(keySelector) - binding.emojiPaletteBackspace.setBackgroundResource(keySelector) - - Thread { - val fullEmojiList = parseRawEmojiSpecsFile(context, EMOJI_SPEC_FILE_PATH) - val systemFontPaint = - android.graphics.Paint().apply { - typeface = android.graphics.Typeface.DEFAULT - } - val emojis = - fullEmojiList.filter { emoji -> - systemFontPaint.hasGlyph(emoji.emoji) - } - - android.os.Handler(android.os.Looper.getMainLooper()).post { - setupEmojiAdapter(emojis, language) - } - }.start() - } - - /** - * Sets up the emoji RecyclerView adapter and category strip. - * - * @param emojis The filtered list of emojis the device can render. - */ - private fun setupEmojiAdapter( - emojis: List, - language: String, - ) { - val recentEmojiChars = getRecentEmojis(context) - val recentEmojiData = recentEmojiChars.mapNotNull { char -> emojis.find { it.emoji == char } } - - val emojiCategories = prepareEmojiCategories(emojis) - val categoriesWithRecents = - if (recentEmojiData.isNotEmpty()) { - linkedMapOf("recently_used" to recentEmojiData) + emojiCategories - } else { - emojiCategories - } - val emojiItems = prepareEmojiItems(categoriesWithRecents) - val categoryHeaders = - (emojiCategoryHeaders["EN"] ?: emptyMap()) + (emojiCategoryHeaders[getLanguageAlias(language)] ?: emptyMap()) - - val emojiItemSize = context.resources.getDimensionPixelSize(R.dimen.emoji_item_size) - val emojiLayoutManager = AutoGridLayoutManager(context, emojiItemSize) - - emojiLayoutManager.spanSizeLookup = - object : GridLayoutManager.SpanSizeLookup() { - override fun getSpanSize(position: Int): Int = - if (emojiItems[position] is EmojiAdapter.Item.Category) { - emojiLayoutManager.spanCount - } else { - 1 - } - } - - binding.emojisList.layoutManager = emojiLayoutManager - binding.emojisList.adapter = - EmojiAdapter(context, emojiItems, categoryHeaders) { emojiData -> - listener.onEmojiSelected(emojiData.emoji) - } - - setupEmojiCategoryStrip(categoriesWithRecents, emojiItems, emojiLayoutManager) - } - - /** - * Groups emojis by category. - * - * @param emojis The full list of emojis. - * @return A map of category name to list of emojis in corresponding category. - */ - private fun prepareEmojiCategories(emojis: List): Map> = emojis.groupBy { it.category } - - /** - * Builds a list of category headers and emoji items for the RecyclerView. - * - * @param categories The map of categories to their emojis. - * @return A flat list of [EmojiAdapter.Item] objects. - */ - private fun prepareEmojiItems(categories: Map>): List { - val emojiItems = mutableListOf() - categories.entries.forEach { (category, emojis) -> - emojiItems.add(EmojiAdapter.Item.Category(category)) - emojis.forEach { emojiItems.add(EmojiAdapter.Item.Emoji(it)) } - } - return emojiItems - } - - /** - * Populates the emoji category strip at the bottom of the palette. - * Tapping a category icon scrolls the emoji list to that category. - * - * @param categories The map of category names to their emojis. - * @param emojiItems The full flat list used to find category positions. - * @param layoutManager The AutoGridLayoutManager used to scroll to positions. - */ - private fun setupEmojiCategoryStrip( - categories: Map>, - emojiItems: List, - layoutManager: AutoGridLayoutManager, - ) { - binding.emojiCategoriesStrip.removeAllViews() - val isDarkMode = getIsDarkModeOrNot(context) - val activeColor = - ContextCompat.getColor( - context, - if (isDarkMode) R.color.nav_bar_selected_color_dark else R.color.nav_bar_selected_color_light, - ) - val inactiveColor = ContextCompat.getColor(context, R.color.nav_item_grey) - - var activeButton: android.widget.ImageButton? = null - - categories.keys.forEachIndexed { index, category -> - val button = - android.widget.ImageButton(context).apply { - setImageResource(getCategoryIconRes(category)) - background = null - imageTintList = - ColorStateList.valueOf(if (index == 0) activeColor else inactiveColor) - layoutParams = - android.widget.LinearLayout.LayoutParams( - 0, - android.widget.LinearLayout.LayoutParams.MATCH_PARENT, - 1f, - ) - setOnClickListener { - activeButton?.imageTintList = ColorStateList.valueOf(inactiveColor) - imageTintList = ColorStateList.valueOf(activeColor) - activeButton = this - - val position = - emojiItems.indexOfFirst { - it is EmojiAdapter.Item.Category && it.value == category - } - if (position != -1) { - (layoutManager as androidx.recyclerview.widget.LinearLayoutManager) - .scrollToPositionWithOffset(position, 0) - } - } - } - - if (index == 0) activeButton = button - binding.emojiCategoriesStrip.addView(button) - } - } - - /** - * Hides the emoji palette and restores the normal keyboard view and command options bar. - * Called when the user taps the close button, the ABC button, or finishes emoji selection. - */ - fun hideEmojiPalette() { - binding.emojiPaletteHolder.visibility = View.GONE - binding.keyboardView.visibility = View.VISIBLE - binding.commandOptionsBar.visibility = View.VISIBLE - binding.emojisList.scrollToPosition(0) - binding.emojiCategoriesStrip.removeAllViews() - } - - /** - * 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) { - val isNumericKeyboard = listener.getCurrentKeyboardLayoutXML() == R.xml.keys_numeric - - 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)) - - if (isNumericKeyboard) { - binding.conjugateBtn.text = "" - binding.conjugateBtn.setOnClickListener(null) - binding.conjugateBtn.visibility = View.GONE - binding.separator2.visibility = View.GONE - binding.separator3.visibility = View.GONE - } else { - val suggestion2 = suggestions.getOrNull(1) ?: "" - binding.conjugateBtn.visibility = View.VISIBLE - binding.conjugateBtn.text = suggestion2 - binding.conjugateBtn.setOnClickListener(createSuggestionClickListener(suggestion2)) - binding.separator2.visibility = View.VISIBLE - binding.separator3.visibility = View.VISIBLE - } - - 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 invalid info text based on current navigation state. - */ - private fun updateWikidataPage() { - binding.middleTextview.text = currentInvalidTexts[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, - ) - } - } - - fun showClipboardSuggestionChip(clipText: String) { - val truncatedText = - if (clipText.length > 25) { - clipText.take(22) + "..." - } else { - clipText - } - binding.clipboardSuggestionChip.text = truncatedText - binding.clipboardSuggestionChip.visibility = View.VISIBLE - - binding.translateBtn.visibility = View.GONE - binding.conjugateBtn.visibility = View.GONE - binding.pluralBtn.visibility = View.GONE - binding.separator2.visibility = View.GONE - binding.separator3.visibility = View.GONE - - binding.emojiBtnPhone1?.visibility = View.GONE - binding.emojiBtnPhone2?.visibility = View.GONE - binding.emojiBtnTablet1?.visibility = View.GONE - binding.emojiBtnTablet2?.visibility = View.GONE - binding.emojiBtnTablet3?.visibility = View.GONE - binding.separator4.visibility = View.GONE - binding.separator5.visibility = View.GONE - binding.separator6.visibility = View.GONE - } - - fun hideClipboardSuggestionChip() { - if (binding.clipboardSuggestionChip.visibility == View.VISIBLE) { - binding.clipboardSuggestionChip.visibility = View.GONE - binding.translateBtn.visibility = View.VISIBLE - binding.conjugateBtn.visibility = View.VISIBLE - binding.pluralBtn.visibility = View.VISIBLE - binding.separator2.visibility = View.VISIBLE - binding.separator3.visibility = View.VISIBLE - } - } - - fun showClipboardPanel() { - binding.clipboardPanelHolder.visibility = View.VISIBLE - keyboardView.visibility = View.INVISIBLE - binding.commandOptionsBar.visibility = View.INVISIBLE - } - - fun hideClipboardPanel() { - binding.clipboardPanelHolder.visibility = View.GONE - keyboardView.visibility = View.VISIBLE - binding.commandOptionsBar.visibility = View.VISIBLE - } -} diff --git a/app/src/keyboards/java/be/scri/services/FrenchKeyboardIME.kt b/app/src/keyboards/java/be/scri/services/FrenchKeyboardIME.kt index bf077ebdf..6ba3bfb29 100644 --- a/app/src/keyboards/java/be/scri/services/FrenchKeyboardIME.kt +++ b/app/src/keyboards/java/be/scri/services/FrenchKeyboardIME.kt @@ -29,11 +29,6 @@ class FrenchKeyboardIME : GeneralKeyboardIME(ScribeLanguage.FRENCH) { override var switchToLetters: Boolean = false override var hasTextBeforeCursor: Boolean = false - // REFACTOR_FIX: The 'binding' and 'keyboardView' properties are no longer abstract in the parent class, - // so we must remove the overrides here. They are now inherited directly. - // override lateinit var binding: KeyboardViewCommandOptionsBinding // REMOVED - // override var keyboardView: KeyboardView? = null // REMOVED - private val keyHandler by lazy { KeyHandler(this) } /** diff --git a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt index a7bbee290..5aa69dbbb 100644 --- a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt +++ b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt @@ -5,8 +5,12 @@ package be.scri.services import DataContract import android.content.Context import android.content.Intent +import android.graphics.Color import android.graphics.Rect import android.inputmethodservice.InputMethodService +import android.inputmethodservice.InputMethodService.BACK_DISPOSITION_ADJUST_NOTHING +import android.inputmethodservice.InputMethodService.BACK_DISPOSITION_DEFAULT +import android.os.Build import android.text.InputType import android.text.InputType.TYPE_CLASS_DATETIME import android.text.InputType.TYPE_CLASS_NUMBER @@ -20,19 +24,26 @@ 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.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.content.ContextCompat import androidx.core.content.edit +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner import be.scri.R import be.scri.activities.MainActivity -import be.scri.databinding.InputMethodViewBinding +import be.scri.extensions.performSoundFeedback 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 import be.scri.helpers.EmojiUtils.insertEmoji -import be.scri.helpers.FloatingKeyboardHandler import be.scri.helpers.KeyboardBase import be.scri.helpers.KeyboardDataHandler import be.scri.helpers.KeyboardLanguageMappingConstants @@ -40,24 +51,32 @@ import be.scri.helpers.KeyboardStateManager import be.scri.helpers.LanguageMappingConstants.getLanguageAlias import be.scri.helpers.NativeSuggestionEngine import be.scri.helpers.PreferencesHelper -import be.scri.helpers.PreferencesHelper.getHoldKeyStyle +import be.scri.helpers.PreferencesHelper.getIsDarkModeOrNot import be.scri.helpers.PreferencesHelper.getIsEmojiSuggestionsEnabled import be.scri.helpers.PreferencesHelper.getIsSoundEnabled import be.scri.helpers.PreferencesHelper.getIsVibrateEnabled -import be.scri.helpers.PreferencesHelper.isShowPopupOnKeypressEnabled import be.scri.helpers.SHIFT_OFF import be.scri.helpers.SHIFT_ON_ONE_CHAR import be.scri.helpers.SHIFT_ON_PERMANENT import be.scri.helpers.SuggestionHandler -import be.scri.helpers.clipboard.ClipboardHandler +import be.scri.helpers.clipboard.ClipboardMonitor +import be.scri.helpers.clipboard.ClipboardRepository import be.scri.helpers.data.AutocompletionDataManager import be.scri.helpers.english.ENInterfaceVariables.ALREADY_PLURAL_MSG import be.scri.helpers.recordRecentEmoji -import be.scri.helpers.ui.KeyboardThemeManager -import be.scri.helpers.ui.KeyboardUIManager +import be.scri.helpers.ui.HintUtils import be.scri.models.ScribeLanguage import be.scri.models.ScribeState -import be.scri.views.KeyboardView +import be.scri.ui.compose.IMSLifecycleOwner +import be.scri.ui.compose.KeyboardActionListener +import be.scri.ui.compose.KeyboardViewModel +import be.scri.ui.compose.ScribeKeyboardApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.Locale private const val DATA_SIZE_2 = 2 @@ -67,26 +86,40 @@ private const val DATA_CONSTANT_3 = 3 abstract class GeneralKeyboardIME( val scribeLanguage: ScribeLanguage, ) : InputMethodService(), - KeyboardView.OnKeyboardActionListener, - KeyboardUIManager.KeyboardUIListener, + KeyboardActionListener, KeyboardBase.KeyboardContextProvider { constructor(languageName: String) : this(ScribeLanguage.fromDisplayName(languageName)) override val language: String get() = scribeLanguage.displayName - // Abstract members required by subclasses (like EnglishKeyboardIME). - abstract override fun getKeyboardLayoutXML(): Int + abstract fun getKeyboardLayoutXML(): Int abstract override val keyboardLetters: Int abstract val keyboardSymbols: Int abstract val keyboardSymbolShift: Int open var keyboard: KeyboardBase? = null - var keyboardView: KeyboardView? = null + set(value) { + field = value + keyboardViewModel.updateKeyboard(value) + } + + internal val keyboardViewModel = KeyboardViewModel() + private var imsLifecycleOwner: IMSLifecycleOwner? = null + private var composeInputView: View? = null - // UI Manager instance. - lateinit var uiManager: KeyboardUIManager + internal fun setShifted(shiftState: Int) { + keyboard?.setShifted(shiftState) + keyboardViewModel.setShiftState(keyboard?.mShiftState ?: SHIFT_OFF) + } + + fun getKeyLabel(code: Int): String? = + keyboard + ?.mKeys + ?.find { it?.code == code } + ?.label + ?.toString() abstract var lastShiftPressTS: Long abstract override var keyboardMode: Int @@ -94,15 +127,10 @@ abstract class GeneralKeyboardIME( abstract var enterKeyType: Int abstract var switchToLetters: Boolean - // Language-specific layout and behavior configurations (decoupled from base class). open val defaultConjugateModeType: String = "3x2" open val defaultConjugateLayoutXML: Int = R.xml.conjugate_view_3x2 open val isPluralCapitalized: Boolean = false - /** - * 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 @@ -113,26 +141,11 @@ abstract class GeneralKeyboardIME( field = value } - // Delegate backspace handling to a separate class. private val backspaceHandler = BackspaceHandler(this) - // Bridge for BackspaceHandler to access binding through UI Manager. - internal val binding: InputMethodViewBinding - get() = uiManager.binding - - internal val clipboardHandler by lazy { ClipboardHandler(this) } - internal var hasNewClip: Boolean - get() = clipboardHandler.hasNewClip - set(value) { - clipboardHandler.hasNewClip = value - } - internal var latestClipText: String? - get() = clipboardHandler.latestClipText - set(value) { - clipboardHandler.latestClipText = value - } - - // MARK: State Variables + internal var hasNewClip: Boolean = false + internal var latestClipText: String? = null + private lateinit var clipboardMonitor: ClipboardMonitor internal var isSingularAndPlural: Boolean = false private var subsequentAreaRequired: Boolean = false @@ -151,18 +164,12 @@ abstract class GeneralKeyboardIME( private lateinit var nativeSuggestionEngine: NativeSuggestionEngine internal lateinit var suggestionHandler: SuggestionHandler internal lateinit var autocompletionHandler: AutocompletionHandler - internal val floatingKeyboardHandler by lazy { FloatingKeyboardHandler(this) } - internal var dataContract: DataContract? get() = dataHandler.dataContract set(value) { dataHandler.dataContract = value } - internal val isUiManagerInitialized: Boolean get() = this::uiManager.isInitialized - - internal fun recreateKeyboardPublic() = recreateKeyboard() - var emojiKeywords: HashMap>? get() = dataHandler.emojiKeywords set(value) { @@ -221,36 +228,42 @@ abstract class GeneralKeyboardIME( private var currentEnterKeyType: Int? = null private var isNumericKeyboardActive: Boolean = false + private var highlightedAutocompleteSuggestion: String? = null + + var emojiColonModeOn: Boolean = false + set(value) { + field = value + keyboardViewModel.setEmojiColonMode(value) + } + internal val stateManager = KeyboardStateManager() - internal val themeManager = KeyboardThemeManager() internal var currentState: ScribeState get() = stateManager.currentState set(value) { stateManager.currentState = value + keyboardViewModel.updateState(value) } internal var invalidCommandSource: ScribeState get() = stateManager.invalidCommandSource set(value) { stateManager.invalidCommandSource = value + keyboardViewModel.setInvalidCommandSource(value) } - // Properties used by BackspaceHandler, delegated to UI Manager. - internal var currentCommandBarHint: String - get() = uiManager.currentCommandBarHint + var commandBarHint: String + get() = keyboardViewModel.commandBarHint.value ?: "" set(value) { - uiManager.currentCommandBarHint = value + keyboardViewModel.setCommandBarHint(value) } - internal var commandBarHintColor: Int - get() = uiManager.commandBarHintColor + var commandBarHintColor: Int + get() = keyboardViewModel.commandBarHintColor.value ?: Color.TRANSPARENT set(value) { - uiManager.commandBarHintColor = value + keyboardViewModel.setCommandBarHintColor(value) } - // MARK: Conjugation State - private var currentVerbForConjugation: String? = null private var selectedConjugationSubCategory: String? = null @@ -260,13 +273,18 @@ abstract class GeneralKeyboardIME( const val SMALLEST_SCREEN_WIDTH_TABLET = 600 const val DEFAULT_SHIFT_PERM_TOGGLE_SPEED = 500 const val TEXT_LENGTH = 20 + const val WORD_LOOKBACK_LENGTH = 50 + const val MAX_COLON_EMOJI_SUGGESTIONS = 9 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 + internal const val CUSTOM_CURSOR = "│" + internal const val FLOATING_TOUCH_MARGIN_DP = 16 + internal const val COMMAND_LABEL_LANGUAGE = "EN" + internal const val COMMAND_LABEL_LANGUAGE_NAME = "English" internal fun shouldUseNumericKeyboard(inputType: Int): Boolean = when (inputType and TYPE_MASK_CLASS) { @@ -285,146 +303,143 @@ abstract class GeneralKeyboardIME( } } - // MARK: Lifecycle Methods - - /** - * Called when the service is first created. Initializes database and suggestion handlers. - */ override fun onCreate() { super.onCreate() dataHandler.initialize(this) nativeSuggestionEngine = NativeSuggestionEngine(this) suggestionHandler = SuggestionHandler(this) autocompletionHandler = AutocompletionHandler(this) - clipboardHandler.initClipboardMonitor() + clipboardMonitor = + ClipboardMonitor(this) { text -> + latestClipText = text + hasNewClip = true + keyboardViewModel.showClipboardSuggestion(text) + } } override fun onDestroy() { + imeScope.cancel() + imsLifecycleOwner?.onDestroy() + imsLifecycleOwner = null if (this::nativeSuggestionEngine.isInitialized) { nativeSuggestionEngine.close() } super.onDestroy() } - /** - * Creates the main view for the input method, inflating it from XML and setting up the keyboard. - * - * @return The root View of the input method. - */ override fun onCreateInputView(): View { - // Initialize UI manager. - val viewBinding = InputMethodViewBinding.inflate(layoutInflater) - uiManager = KeyboardUIManager(viewBinding, this, this) - keyboardView = uiManager.keyboardView - - // Initial keyboard setup. - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType, getKeyboardWidth()) + keyboardViewModel.updateLanguage(language) - keyboardView?.apply { - setVibrate = getIsVibrateEnabled(applicationContext, language) - setSound = getIsSoundEnabled(applicationContext, language) - setHoldForAltCharacters = getHoldKeyStyle(applicationContext, language) - setKeyboard(this@GeneralKeyboardIME.keyboard!!) - mOnKeyboardActionListener = this@GeneralKeyboardIME - } + keyboardViewModel.updateCommandLabels( + KeyboardLanguageMappingConstants.translatePlaceholder[COMMAND_LABEL_LANGUAGE] ?: "Translate", + KeyboardLanguageMappingConstants.conjugatePlaceholder[COMMAND_LABEL_LANGUAGE] ?: "Conjugate", + KeyboardLanguageMappingConstants.pluralPlaceholder[COMMAND_LABEL_LANGUAGE] ?: "Plural", + ) - currentState = ScribeState.IDLE saveConjugateModeType("none") + keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType, getKeyboardWidth()) - viewBinding.root.post { - disableParentClipping(viewBinding.root) - } + currentState = ScribeState.IDLE initFloatingMode() - setupFloatingDragListener() - refreshUI() + val lifecycleOwner = + imsLifecycleOwner ?: IMSLifecycleOwner().also { + it.onCreate() + imsLifecycleOwner = it + } - return viewBinding.root + window?.window?.decorView?.let { decor -> + decor.setViewTreeLifecycleOwner(lifecycleOwner) + decor.setViewTreeViewModelStoreOwner(lifecycleOwner) + decor.setViewTreeSavedStateRegistryOwner(lifecycleOwner) + } + + return ComposeView(this) + .apply { + setViewTreeLifecycleOwner(lifecycleOwner) + setViewTreeViewModelStoreOwner(lifecycleOwner) + setViewTreeSavedStateRegistryOwner(lifecycleOwner) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> + val navBarBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom + keyboardViewModel.setBottomInset(if (isFloatingMode) 0 else navBarBottom) + insets + } + androidx.core.view.ViewCompat + .requestApplyInsets(this) + setContent { + ScribeKeyboardApp( + viewModel = keyboardViewModel, + actionListener = this@GeneralKeyboardIME, + ) + } + }.also { composeInputView = it } } - /** - * 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. - */ override fun onEvaluateInputViewShown(): Boolean { super.onEvaluateInputViewShown() return true } - /** - * Disable fullscreen mode to ensure the keyboard displays correctly on API 36 onwards. - * Fullscreen mode can interfere with edge-to-edge layouts. - */ override fun onEvaluateFullscreenMode(): Boolean = false - /** - * Compute the insets for the keyboard view. This is essential for API 36+ - * where the system needs to know the exact size of the keyboard to properly - * handle edge-to-edge display and window insets. - */ override fun onComputeInsets(outInsets: Insets) { super.onComputeInsets(outInsets) - if (this::uiManager.isInitialized) { - val inputView = uiManager.binding.root - if (inputView.visibility == View.VISIBLE && inputView.height > 0) { - val location = IntArray(2) - inputView.getLocationInWindow(location) - - if (isFloatingMode) { - // In floating mode, report zero insets so Android doesn't - // push app content up or render IME chrome (∨ / 🌐 buttons) - // below the card. The touchable region is restricted to the - // card bounds so taps outside reach the underlying app. - outInsets.visibleTopInsets = inputView.height - outInsets.contentTopInsets = inputView.height - outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_REGION - - val card = binding.keyboardCard - val density = resources.displayMetrics.density - if (card.width > 0 && card.height > 0) { - val centerX = card.left + card.width / 2f + card.translationX - val centerY = card.top + card.height / 2f + card.translationY - val visualW = card.width * card.scaleX - val visualH = card.height * card.scaleY - val left = (centerX - visualW / 2f).toInt() - val top = (centerY - visualH / 2f).toInt() - val right = (centerX + visualW / 2f).toInt() - val bottom = (centerY + visualH / 2f).toInt() - - val rect = Rect(left, top, right, bottom) - // Expand touchable region slightly to allow resizing handles to be clickable - val margin = (25 * density).toInt() - rect.inset(-margin, -margin) - outInsets.touchableRegion.set(rect) - } else { - outInsets.touchableRegion.setEmpty() - } + val inputView = composeInputView ?: return + if (inputView.visibility == View.VISIBLE && inputView.height > 0) { + val location = IntArray(2) + inputView.getLocationInWindow(location) + + if (isFloatingMode) { + outInsets.visibleTopInsets = inputView.height + outInsets.contentTopInsets = inputView.height + outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_REGION + + val density = resources.displayMetrics.density + val card = keyboardViewModel.floatingCardBounds.value + if (card.width > 0f && card.height > 0f) { + val offsetX = keyboardViewModel.floatingOffsetX.value + val offsetY = keyboardViewModel.floatingOffsetY.value + val scaleX = keyboardViewModel.floatingScaleX.value + val scaleY = keyboardViewModel.floatingScaleY.value + + val centerX = card.left + card.width / 2f + offsetX + val centerY = card.top + card.height / 2f + offsetY + val visualW = card.width * scaleX + val visualH = card.height * scaleY + val left = (centerX - visualW / 2f).toInt() + val top = (centerY - visualH / 2f).toInt() + val right = (centerX + visualW / 2f).toInt() + val bottom = (centerY + visualH / 2f).toInt() + + val rect = Rect(left, top, right, bottom) + val margin = (FLOATING_TOUCH_MARGIN_DP * density).toInt() + rect.inset(-margin, -margin) + outInsets.touchableRegion.set(rect) } else { - outInsets.visibleTopInsets = location[1] - outInsets.contentTopInsets = location[1] - outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE + outInsets.touchableRegion.setEmpty() } + } else { + outInsets.visibleTopInsets = location[1] + outInsets.contentTopInsets = location[1] + outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE } } } override fun onWindowShown() { super.onWindowShown() + imsLifecycleOwner?.let { lifecycleOwner -> + window?.window?.decorView?.let { decor -> + decor.setViewTreeLifecycleOwner(lifecycleOwner) + decor.setViewTreeViewModelStoreOwner(lifecycleOwner) + decor.setViewTreeSavedStateRegistryOwner(lifecycleOwner) + } + } applyFloatingModeState() applyNavBarColor() - keyboardView?.setPreview = isShowPopupOnKeypressEnabled(applicationContext, language) - keyboardView?.setVibrate = getIsVibrateEnabled(applicationContext, language) - keyboardView?.setSound = getIsSoundEnabled(applicationContext, language) - keyboardView?.setHoldForAltCharacters = getHoldKeyStyle(applicationContext, language) - } - - /** - * 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. - * - * @param attribute The editor information for the new input field. - * @param restarting true if we are restarting the input with the same editor. - */ + } + override fun onStartInput( attribute: EditorInfo?, restarting: Boolean, @@ -434,7 +449,6 @@ abstract class GeneralKeyboardIME( 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 isNumericKeyboardActive = shouldUseNumericKeyboard(attribute.inputType) @@ -444,28 +458,20 @@ abstract class GeneralKeyboardIME( loadLanguageData() keyboard = KeyboardBase(this, keyboardXml, enterKeyType, getKeyboardWidth()) - keyboardView?.setKeyboard(keyboard!!) - - if (this::uiManager.isInitialized && keyboardXml == R.xml.keys_symbols) { - uiManager.setupCurrencySymbol(language) - } } - /** - * Called when the input view is starting. It sets up the UI theme, emoji settings, - * and initial keyboard state. - * - * @param editorInfo The editor information for the input field. - * @param restarting true if we are restarting the input with the same editor. - */ override fun onStartInputView( editorInfo: EditorInfo?, restarting: Boolean, ) { super.onStartInputView(editorInfo, restarting) - clipboardHandler.startMonitoring() + imsLifecycleOwner?.onResume() + if (this::clipboardMonitor.isInitialized) { + clipboardMonitor.startMonitoring() + } emojiAutoSuggestionEnabled = getIsEmojiSuggestionsEnabled(applicationContext, language) autoSuggestEmojis = null + emojiColonModeOn = false suggestionHandler.clearAllSuggestionsAndHideButtonUI() moveToIdleState() @@ -473,63 +479,28 @@ abstract class GeneralKeyboardIME( val languageAlias = getLanguageAlias(language) val dbFile = applicationContext.getDatabasePath("${languageAlias}LanguageData.sqlite") val hasData = dbFile.exists() - val bannerContainer = binding.root.findViewById(R.id.empty_state_banner_container) - val banner = binding.root.findViewById(R.id.empty_state_banner) - val downloadDataText = - KeyboardLanguageMappingConstants.downloadDataPlaceholder[languageAlias] - ?: "Please download language data" - banner.text = downloadDataText - bannerContainer.visibility = - if (hasData) View.GONE else View.VISIBLE - binding.commandOptionsBar.visibility = - if (hasData && !isNumericKeyboardActive) View.VISIBLE else View.GONE - themeManager.applyBannerTheme( - context = applicationContext, - banner = banner, - bannerContainer = bannerContainer, - ) - - bannerContainer.setOnClickListener { - val intent = - Intent(applicationContext, MainActivity::class.java) - .apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - putExtra("navigate_to", "download_data") - } - - startActivity(intent) - } + keyboardViewModel.setHasData(hasData) + keyboardViewModel.setHasLanguageData(hasData) applyNavBarColor() - // Set initial shift state for empty text fields. if (keyboardMode == keyboardLetters) { val textBefore = currentInputConnection?.getTextBeforeCursor(1, 0)?.toString().orEmpty() if (textBefore.isEmpty()) { - keyboardView?.mKeyboard?.mShiftState = SHIFT_ON_ONE_CHAR + setShifted(SHIFT_ON_ONE_CHAR) } - keyboardView?.invalidateAllKeys() } } - /** - * 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) - clipboardHandler.stopMonitoring() + imsLifecycleOwner?.onPause() + if (this::clipboardMonitor.isInitialized) { + clipboardMonitor.stopMonitoring() + } moveToIdleState() } - // MARK: OnKeyboardActionListener - - /** - * Interface method called by KeyboardView. - * Delegates to the property 'hasTextBeforeCursor' which subclasses may override. - */ override fun hasTextBeforeCursor(): Boolean = hasTextBeforeCursor override fun commitPeriodAfterSpace() { @@ -541,21 +512,21 @@ abstract class GeneralKeyboardIME( } } - /** - * 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() + keyboardViewModel.showClipboardSuggestion(null) + if (primaryCode != 0) { + val view = window?.window?.decorView + if (view != null) { + if (getIsVibrateEnabled(applicationContext, language)) { + view.performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY) + } + if (getIsSoundEnabled(applicationContext, language)) { + view.performSoundFeedback() + } + } + } } - /** - * 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 @@ -563,10 +534,9 @@ abstract class GeneralKeyboardIME( 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) + setShifted(SHIFT_ON_ONE_CHAR) } } - keyboardView!!.setKeyboard(keyboard!!) switchToLetters = false } } @@ -576,13 +546,12 @@ abstract class GeneralKeyboardIME( override fun moveCursorRight() = moveCursor(true) override fun onText(text: String) { + keyboardViewModel.showClipboardSuggestion(null) currentInputConnection?.commitText(text, 0) } - /** - * Handles key input from the keyboard. Delegates to specific handlers based on the key code. - */ override fun onKey(code: Int) { + keyboardViewModel.showClipboardSuggestion(null) when (code) { KeyboardBase.KEYCODE_EMOJI -> { openEmojiKeyboard() @@ -603,25 +572,25 @@ abstract class GeneralKeyboardIME( KeyboardBase.KEYCODE_DELETE -> handleDelete() KeyboardBase.KEYCODE_SHIFT -> { if (keyboardMode == keyboardLetters) { - val shiftState = keyboardView?.mKeyboard?.mShiftState ?: SHIFT_OFF + val shiftState = keyboard?.mShiftState ?: SHIFT_OFF when { - shiftState == SHIFT_ON_PERMANENT -> keyboardView?.setShifted(SHIFT_OFF) - System.currentTimeMillis() - lastShiftPressTS < shiftPermToggleSpeed -> keyboardView?.setShifted(SHIFT_ON_PERMANENT) - shiftState == SHIFT_ON_ONE_CHAR -> keyboardView?.setShifted(SHIFT_OFF) - shiftState == SHIFT_OFF -> keyboardView?.setShifted(SHIFT_ON_ONE_CHAR) + shiftState == SHIFT_ON_PERMANENT -> setShifted(SHIFT_OFF) + System.currentTimeMillis() - lastShiftPressTS < shiftPermToggleSpeed -> setShifted(SHIFT_ON_PERMANENT) + shiftState == SHIFT_ON_ONE_CHAR -> setShifted(SHIFT_OFF) + shiftState == SHIFT_OFF -> setShifted(SHIFT_ON_ONE_CHAR) } lastShiftPressTS = System.currentTimeMillis() } else { - handleModeChange(keyboardMode, keyboardView, this) + handleModeChange(keyboardMode, this) } } KeyboardBase.KEYCODE_ENTER -> handleKeycodeEnter() - KeyboardBase.KEYCODE_MODE_CHANGE -> handleModeChange(keyboardMode, keyboardView, this) + KeyboardBase.KEYCODE_MODE_CHANGE -> handleModeChange(keyboardMode, this) KeyboardBase.KEYCODE_CLIPBOARD -> openClipboardPanel() else -> { if (KeyboardBase.SCRIBE_VIEW_KEYS.contains(code)) { - val keyLabel = keyboardView?.getKeyLabel(code) + val keyLabel = getKeyLabel(code) if (!keyLabel.isNullOrEmpty()) { commitText("$keyLabel ") } @@ -634,10 +603,8 @@ abstract class GeneralKeyboardIME( } } - // MARK: Helper Methods - fun openEmojiKeyboard() { - uiManager.showEmojiPalette(language) + keyboardViewModel.setEmojiKeyboardVisible(true) } protected fun isPeriodAndCommaEnabled(): Boolean { @@ -646,17 +613,6 @@ abstract class GeneralKeyboardIME( return isPreferenceEnabled || isInSearchBar } - /** - * 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. - */ override fun isSearchBar(): Boolean { val editorInfo = currentInputEditorInfo val isActionSearch = (enterKeyType == EditorInfo.IME_ACTION_SEARCH) @@ -668,25 +624,72 @@ abstract class GeneralKeyboardIME( return isActionSearch || isUriType || hasSearchHint } + private val imeScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private fun loadLanguageData() { - dataHandler.loadLanguageData(language) + val currentLanguage = language + imeScope.launch { + withContext(Dispatchers.IO) { + dataHandler.loadLanguageData(currentLanguage) + } + } } - internal fun applyNavBarColor() { - themeManager.applyNavBarColor( - service = this, - window = window?.window, - isFloatingMode = isFloatingMode, - uiManager = if (this::uiManager.isInitialized) uiManager else null, - ) + 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 + } + + private fun applyNavBarColor() { + val window = window?.window ?: return + window.decorView.post { + val isDarkMode = getIsDarkModeOrNot(applicationContext) + val colorRes = if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color + val color = ContextCompat.getColor(this, colorRes) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + if (Build.VERSION.SDK_INT < 35) { + window.navigationBarColor = Color.TRANSPARENT + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + + if (isFloatingMode) { + window.decorView.setBackgroundColor(Color.TRANSPARENT) + } else { + window.decorView.setBackgroundColor(color) + } + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + insetsController.isAppearanceLightNavigationBars = isLightColor(color) + + if (isFloatingMode) { + insetsController.hide(WindowInsetsCompat.Type.navigationBars()) + insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + } else { + insetsController.show(WindowInsetsCompat.Type.navigationBars()) + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = 0 + } + + composeInputView?.let { inputView -> + if (isFloatingMode) { + inputView.setBackgroundColor(Color.TRANSPARENT) + } else { + inputView.setBackgroundColor(color) + } + ViewCompat.requestApplyInsets(inputView) + } + } } - /** - * 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 = this.language, isSubsequentArea: Boolean = false, @@ -696,64 +699,46 @@ abstract class GeneralKeyboardIME( sharedPref.edit { putString("conjugate_mode_type", mode) } } - // MARK: UI Update Delegation - - /** - * The main dispatcher for updating the entire keyboard UI. It calls the appropriate setup function - * based on the current [ScribeState]. - */ - internal fun updateUI() = refreshUI() - - private fun refreshUI() { - if (!this::uiManager.isInitialized) return - - uiManager.updateUI( - currentState = currentState, - language = language, - emojiAutoSuggestionEnabled = emojiAutoSuggestionEnabled, - autoSuggestEmojis = autoSuggestEmojis, - conjugateOutput = conjugateOutput, - conjugateLabels = conjugateLabels, - selectedConjugationSubCategory = selectedConjugationSubCategory, - currentVerbForConjugation = currentVerbForConjugation, - invalidCommandSource = invalidCommandSource, - ) + private fun enterInvalidState(source: ScribeState) { + invalidCommandSource = source + currentState = ScribeState.INVALID } - /** - * Transitions the keyboard to the `IDLE` state and updates the UI. - */ internal fun moveToIdleState() { clearSuggestionData() - stateManager.moveToIdle() + currentState = ScribeState.IDLE saveConjugateModeType("none") currentVerbForConjugation = null selectedConjugationSubCategory = null - if (this::uiManager.isInitialized) refreshUI() + keyboardViewModel.setPromptText("") + keyboardViewModel.setCommandBarText("") + keyboardViewModel.updateConjugateData(null, null, null) } - /** - * Clears all cached suggestion data. - */ private fun clearSuggestionData() { + emojiColonModeOn = false + highlightedAutocompleteSuggestion = null + keyboardViewModel.setHighlightedSuggestion(null) + keyboardViewModel.setAutocompleteActive(false) autoSuggestEmojis = null nounTypeSuggestion = null caseAnnotationSuggestion = null isSingularAndPlural = false + keyboardViewModel.setSuggestions(null, null, null) + keyboardViewModel.setGenderSuggestions(null, null) + keyboardViewModel.updateEmojiSuggestions(emptyList()) } - // MARK: KeyboardUIListener - override fun onScribeKeyOptionsClicked() { + keyboardViewModel.showClipboardSuggestion(null) if (stateManager.isIdle) { clearSuggestionData() - stateManager.moveToState(ScribeState.SELECT_COMMAND) + currentState = ScribeState.SELECT_COMMAND saveConjugateModeType("none") currentVerbForConjugation = null } else { moveToIdleState() } - refreshUI() } override fun onScribeKeyToolbarClicked() { @@ -761,52 +746,96 @@ abstract class GeneralKeyboardIME( } override fun onTranslateClicked() { - stateManager.moveToState(ScribeState.TRANSLATE) + currentState = ScribeState.TRANSLATE saveConjugateModeType("none") - refreshUI() + keyboardViewModel.setPromptText( + HintUtils.getPromptText(ScribeState.TRANSLATE, language, applicationContext, null), + ) + keyboardViewModel.setCommandBarHint( + HintUtils.getCommandBarHint(ScribeState.TRANSLATE, COMMAND_LABEL_LANGUAGE_NAME, null), + ) } override fun onConjugateClicked() { - if (stateManager.currentState != ScribeState.SELECT_VERB_CONJUNCTION) { - stateManager.moveToState(ScribeState.CONJUGATE) + if (currentState != ScribeState.SELECT_VERB_CONJUNCTION) { + currentState = ScribeState.CONJUGATE + keyboardViewModel.setPromptText( + HintUtils.getPromptText( + ScribeState.CONJUGATE, + COMMAND_LABEL_LANGUAGE_NAME, + applicationContext, + null, + ), + ) + keyboardViewModel.setCommandBarHint( + HintUtils.getCommandBarHint(ScribeState.CONJUGATE, COMMAND_LABEL_LANGUAGE_NAME, null), + ) } - refreshUI() } override fun onPluralClicked() { - stateManager.moveToState(ScribeState.PLURAL) + currentState = ScribeState.PLURAL saveConjugateModeType("none") - if (isPluralCapitalized) keyboard?.mShiftState = SHIFT_ON_ONE_CHAR - refreshUI() + keyboardViewModel.setPromptText( + HintUtils.getPromptText( + ScribeState.PLURAL, + COMMAND_LABEL_LANGUAGE_NAME, + applicationContext, + null, + ), + ) + keyboardViewModel.setCommandBarHint( + HintUtils.getCommandBarHint(ScribeState.PLURAL, COMMAND_LABEL_LANGUAGE_NAME, null), + ) + if (language == "German" || isPluralCapitalized) setShifted(SHIFT_ON_ONE_CHAR) } override fun onCloseClicked() { moveToIdleState() } - override fun onFloatClicked() { - toggleFloatingMode() - } - override fun isFloatingModeActive(): Boolean = isFloatingMode + override fun onDownloadDataBannerClicked() { + val intent = + Intent(applicationContext, MainActivity::class.java) + .apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra("navigate_to", "download_data") + } + startActivity(intent) + } + override fun onEmojiSelected(emoji: String) { if (emoji.isNotEmpty()) { recordRecentEmoji(this, emoji) - insertEmoji(emoji, currentInputConnection, emojiKeywords, emojiMaxKeywordLength) + insertEmoji(emoji, currentInputConnection, emojiKeywords, emojiMaxKeywordLength, emojiColonModeOn) + if (emojiColonModeOn) { + emojiColonModeOn = false + clearAutocomplete() + } } } - override fun onSuggestionClicked(suggestion: String) { - currentInputConnection?.commitText("$suggestion ", 1) + override fun onAutocompleteSuggestionClicked(suggestion: String) { + replaceCurrentWordWithSuggestion(suggestion) moveToIdleState() } - override fun getCurrentEnterKeyType(): Int = enterKeyType + override fun onSuggestionClicked(suggestion: String) { + if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { + commitText(suggestion) + } else { + currentInputConnection?.commitText("$suggestion ", 1) + moveToIdleState() + } + } + + fun getCurrentEnterKeyType(): Int = enterKeyType - override fun isNumericKeyboardActive(): Boolean = isNumericKeyboardActive + fun isNumericKeyboardActive(): Boolean = isNumericKeyboardActive - override fun getCurrentKeyboardLayoutXML(): Int = + fun getCurrentKeyboardLayoutXML(): Int = when (keyboardMode) { keyboardSymbols -> getPrimarySymbolKeyboardLayoutXML() keyboardSymbolShift -> R.xml.keys_symbols_shift @@ -820,13 +849,11 @@ abstract class GeneralKeyboardIME( R.xml.keys_symbols } - override fun onKeyboardActionListener(): KeyboardView.OnKeyboardActionListener = this - - override fun processLinguisticSuggestions(word: String) { + fun processLinguisticSuggestions(word: String) { suggestionHandler.processLinguisticSuggestions(word) } - override fun commitText(text: String) { + fun commitText(text: String) { if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { val label = text.trim() val conjugateIndex = getValidatedConjugateIndex() @@ -842,7 +869,7 @@ abstract class GeneralKeyboardIME( val (key, values) = matchingEntry if (values.size > 1) { selectedConjugationSubCategory = key - refreshUI() + keyboardViewModel.updateConjugateData(conjugateOutput, selectedConjugationSubCategory, currentVerbForConjugation) return } } @@ -857,12 +884,6 @@ abstract class GeneralKeyboardIME( } } - // MARK: Input Logic - - /** - * Handles the logic for the Enter key press. This can either perform an editor action, - * commit a newline, or execute a Scribe command depending on the current state. - */ fun handleKeycodeEnter() { val inputConnection = currentInputConnection ?: return @@ -876,7 +897,10 @@ abstract class GeneralKeyboardIME( return } - val rawInput = uiManager.getCommandBarTextWithoutCursor().trim().takeIf { it.isNotEmpty() } + val rawInput = + keyboardViewModel.commandBarText.value + ?.trim() + ?.takeIf { it.isNotEmpty() } if (rawInput == null) { moveToIdleState() @@ -889,12 +913,6 @@ abstract class GeneralKeyboardIME( } } - /** - * Handles the Enter key press when in the plural or translate state. - * - * @param rawInput The text from the command bar. - * @param inputConnection The current input connection. - */ private fun handlePluralOrTranslateState( rawInput: String, inputConnection: InputConnection, @@ -907,7 +925,6 @@ abstract class GeneralKeyboardIME( when (val pluralResult = getPluralRepresentation(rawInput)) { ALREADY_PLURAL_MSG -> { currentState = ScribeState.ALREADY_PLURAL - refreshUI() return } @@ -925,19 +942,12 @@ abstract class GeneralKeyboardIME( } if (commandModeOutput.isEmpty()) { - stateManager.setInvalidState(currentState) - refreshUI() + enterInvalidState(currentState) } else { applyCommandOutput(commandModeOutput, inputConnection) } } - /** - * Handles the Enter key press when in the `CONJUGATE` state. It fetches the - * conjugation data for the entered verb and transitions to the selection view. - * - * @param rawInput The verb entered in the command bar. - */ private fun handleConjugateState(rawInput: String) { val searchInput = rawInput.lowercase() currentVerbForConjugation = rawInput @@ -960,21 +970,14 @@ abstract class GeneralKeyboardIME( conjugateLabels = dbManagers.conjugateDataManager.extractConjugateHeadings(dataContract, searchInput) if (conjugateOutput == null) { - stateManager.setInvalidState(ScribeState.CONJUGATE) + enterInvalidState(ScribeState.CONJUGATE) } else { saveConjugateModeType(language) - stateManager.moveToState(ScribeState.SELECT_VERB_CONJUNCTION) + currentState = ScribeState.SELECT_VERB_CONJUNCTION } - refreshUI() + keyboardViewModel.updateConjugateData(conjugateOutput, selectedConjugationSubCategory, currentVerbForConjugation) } - /** - * Handles the default behavior of the Enter key when not in a special Scribe command mode. - * - * It performs the editor action or sends a standard Enter key event. - * - * @param inputConnection The current input connection. - */ private fun handleDefaultEnter(inputConnection: InputConnection) { val wordBeforeEnter = getLastWordBeforeCursor() val imeOptionsActionId = getImeOptionsActionId() @@ -992,12 +995,6 @@ abstract class GeneralKeyboardIME( } } - /** - * Commits the output of a Scribe command (like translation or pluralization) to the input field. - * - * @param commandModeOutput The string result of the command. - * @param inputConnection The current input connection. - */ private fun applyCommandOutput( commandModeOutput: String, inputConnection: InputConnection, @@ -1007,24 +1004,16 @@ abstract class GeneralKeyboardIME( inputConnection.commitText(output, COMMIT_TEXT_CURSOR_POSITION) suggestionHandler.processLinguisticSuggestions(output.trim()) } - uiManager.binding.commandBar.setText("") + keyboardViewModel.setCommandBarText("") moveToIdleState() } - /** - * Handles the input of any non-special character key (e.g., letters, numbers, punctuation). - * It commits the character to the main input field or the command bar. - * - * @param code The character code of the key. - * @param keyboardMode The current keyboard mode. - * @param commandBarState true if input should go to the command bar. - */ fun handleElseCondition( code: Int, keyboardMode: Int, commandBarState: Boolean = false, ) { - val currentShiftState = keyboardView?.mKeyboard?.mShiftState ?: SHIFT_OFF + val currentShiftState = keyboard?.mShiftState ?: SHIFT_OFF if (commandBarState) { val codeChar = if (Character.isLetter(code.toChar()) && currentShiftState > SHIFT_OFF) { @@ -1032,14 +1021,14 @@ abstract class GeneralKeyboardIME( } else { code.toChar() } - val currentTextWithoutCursor = uiManager.getCommandBarTextWithoutCursor() + val currentTextWithoutCursor = keyboardViewModel.commandBarText.value ?: "" - if (currentTextWithoutCursor == currentCommandBarHint) { - uiManager.binding.commandBar.setTextColor(uiManager.commandBarTextColor) - uiManager.setCommandBarTextWithCursor(codeChar.toString()) + if (currentTextWithoutCursor == commandBarHint) { + keyboardViewModel.setCommandBarHintColor(commandBarHintColor) + keyboardViewModel.setCommandBarText(codeChar.toString()) } else { val newText = currentTextWithoutCursor + codeChar - uiManager.setCommandBarTextWithCursor(newText) + keyboardViewModel.setCommandBarText(newText) } } else { val inputConnection = currentInputConnection ?: return @@ -1059,21 +1048,10 @@ abstract class GeneralKeyboardIME( } if (currentShiftState == SHIFT_ON_ONE_CHAR && keyboardMode == keyboardLetters) { - keyboardView?.mKeyboard?.mShiftState = SHIFT_OFF - keyboardView?.invalidateAllKeys() + setShifted(SHIFT_OFF) } } - // MARK: Deletion Logic - - /** - * Handles the logic for the Delete key. It deletes characters from either - * the main input field or the command bar, depending on the context. - * Delegated to BackspaceHandler. - * - * @param isCommandBar true` if the deletion should happen in the command bar. - * @param isLongPress true` if this is a long press/repeat action, false for single tap. - */ fun handleDelete(isLongPress: Boolean = false) { val inputConnection = currentInputConnection ?: return val effectiveIsCommandBar = @@ -1083,7 +1061,6 @@ abstract class GeneralKeyboardIME( if (!effectiveIsCommandBar) { val selectedText = inputConnection.getSelectedText(0) if (selectedText.isNullOrEmpty()) { - // Use BreakIterator to delete full emoji characters. val prevText = inputConnection.getTextBeforeCursor(8, 0) if (!prevText.isNullOrEmpty()) { val breakIterator = @@ -1107,78 +1084,56 @@ abstract class GeneralKeyboardIME( backspaceHandler.handleBackspace(effectiveIsCommandBar, isLongPress) } - /** - * Returns whether the delete key is currently repeating (long press). - * Delegated to BackspaceHandler. - */ fun isDeleteRepeating() = backspaceHandler.isDeleteRepeating - /** - * Sets the flag to indicate that the delete key is currently repeating (long press). - * Delegated to BackspaceHandler. - */ override fun setDeleteRepeating(repeating: Boolean) { backspaceHandler.isDeleteRepeating = repeating } - // MARK: State & Logic Helpers + data class AutocompleteResult( + val completions: List, + val highlightedSuggestion: String?, + ) - /** - * 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 { - if (this::nativeSuggestionEngine.isInitialized) { - val nativeCompletions = nativeSuggestionEngine.getAutocompletions(language, prefix, limit) - if (nativeCompletions.isNotEmpty()) { - return nativeCompletions + ): AutocompleteResult { + val completions = + dataHandler.getAutocompletions(prefix, limit).ifEmpty { + if (this::nativeSuggestionEngine.isInitialized) { + nativeSuggestionEngine.getAutocompletions(language, prefix, limit) + } else { + emptyList() + } } + + val isPrefixItselfAValidWord = + this::nativeSuggestionEngine.isInitialized && nativeSuggestionEngine.isValidWord(language, prefix) + + if (isPrefixItselfAValidWord && completions.none { it.equals(prefix, ignoreCase = true) }) { + return AutocompleteResult((listOf(prefix) + completions).take(limit), highlightedSuggestion = prefix) } - return dataHandler.getAutocompletions(prefix, limit) + + if (!isPrefixItselfAValidWord && completions.size == 1) { + val onlyCompletion = completions.first() + return AutocompleteResult(listOf(onlyCompletion, prefix).take(limit), highlightedSuggestion = onlyCompletion) + } + + return AutocompleteResult(completions, highlightedSuggestion = null) } - /** - * Gets the current text in the command bar without the cursor. - * - * @return The text content without the trailing cursor character. - */ - fun getCommandBarTextWithoutCursor() = uiManager.getCommandBarTextWithoutCursor() + fun getCommandBarTextWithoutCursor() = keyboardViewModel.commandBarText.value ?: "" - /** - * 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 - */ fun setCommandBarTextWithCursor( text: String, cursorAtStart: Boolean = false, - ) = uiManager.setCommandBarTextWithCursor(text, cursorAtStart) + ) = keyboardViewModel.setCommandBarText(text) - /** - * Extracts the last word from the text immediately preceding the cursor. - * - * @return The last word as a [String], or null if no word is found. - */ fun getLastWordBeforeCursor(): String? = getText()?.trim()?.split("\\s+".toRegex())?.lastOrNull() - /** - * Retrieves the text immediately preceding the cursor. - * - * @return The text before the cursor, up to a defined maximum length. - */ fun getText(): String? = currentInputConnection?.getTextBeforeCursor(TEXT_LENGTH, 0)?.toString() - // MARK: Misc Private Helpers - - /** - * Gets the IME action ID (e.g., Go, Search, Done) from the current editor info. - * - * @return The IME action ID, or `IME_ACTION_NONE`. - */ private fun getImeOptionsActionId(): Int = if (currentInputEditorInfo.imeOptions and IME_FLAG_NO_ENTER_ACTION != 0) { IME_ACTION_NONE @@ -1186,37 +1141,13 @@ abstract class GeneralKeyboardIME( currentInputEditorInfo.imeOptions and IME_MASK_ACTION } - /** - * Retrieves the plural form of a word from the database. - * - * @param word The singular word to find the plural for. - * - * @return The plural form as a string, or null if not found. - */ private fun getPluralRepresentation(word: String?): String? = dataHandler.getPluralRepresentation(language, word) - /** - * Retrieves the translation for a given word. - * - * @param language The current keyboard language (destination language). - * @param commandBarInput The word to be translated (source word). - * - * @return The translated word as a string. - */ private fun getTranslation( language: String, commandBarInput: String, ): String = dataHandler.getTranslation(language, commandBarInput) - /** - * Applies capitalization to all conjugated forms in the output map. - * Supports both standard capitalization (first letter) and all capital letters formatting. - * - * @param conjugations The original map of conjugations from the database. - * @param isAllCaps If true, applies all capital letters; if false, capitalizes only first letter. - * - * @return A new map with properly formatted conjugations. - */ private fun applyCapitalizationToConjugations( conjugations: MutableMap>>, isAllCaps: Boolean = false, @@ -1240,12 +1171,6 @@ abstract class GeneralKeyboardIME( return formattedOutput } - /** - * Retrieves and validates the stored index for the current conjugation view. - * Ensures the index is within the bounds of available conjugation types. - * - * @return A valid, zero-based index for the conjugation type. - */ private fun getValidatedConjugateIndex(): Int { val prefs = getSharedPreferences("keyboard_preferences", MODE_PRIVATE) var index = prefs.getInt("conjugate_index", 0) @@ -1255,23 +1180,14 @@ abstract class GeneralKeyboardIME( return index } - /** - * Handles the logic for the Shift key. It cycles through shift states (off, on-for-one-char, caps lock) - * on the letter keyboard, and toggles between symbol pages on the symbol keyboard. - * @param keyboardMode The current keyboard mode. - * @param keyboardView The instance of the keyboard view. - */ - fun handleKeyboardLetters( - keyboardMode: Int, - keyboardView: KeyboardView?, - ) { + fun handleKeyboardLetters(keyboardMode: Int) { if (keyboardMode == keyboardLetters) { - val shiftState = keyboardView?.mKeyboard?.mShiftState ?: SHIFT_OFF + val shiftState = keyboard?.mShiftState ?: SHIFT_OFF when { - shiftState == SHIFT_ON_PERMANENT -> keyboardView?.setShifted(SHIFT_OFF) - System.currentTimeMillis() - lastShiftPressTS < shiftPermToggleSpeed -> keyboardView?.setShifted(SHIFT_ON_PERMANENT) - shiftState == SHIFT_ON_ONE_CHAR -> keyboardView?.setShifted(SHIFT_OFF) - shiftState == SHIFT_OFF -> keyboardView?.setShifted(SHIFT_ON_ONE_CHAR) + shiftState == SHIFT_ON_PERMANENT -> setShifted(SHIFT_OFF) + System.currentTimeMillis() - lastShiftPressTS < shiftPermToggleSpeed -> setShifted(SHIFT_ON_PERMANENT) + shiftState == SHIFT_ON_ONE_CHAR -> setShifted(SHIFT_OFF) + shiftState == SHIFT_OFF -> setShifted(SHIFT_ON_ONE_CHAR) } lastShiftPressTS = System.currentTimeMillis() } else { @@ -1284,23 +1200,14 @@ abstract class GeneralKeyboardIME( getPrimarySymbolKeyboardLayoutXML() } keyboard = KeyboardBase(this, keyboardXml, enterKeyType, getKeyboardWidth()) - keyboardView!!.setKeyboard(keyboard!!) if (keyboardXml == R.xml.keys_symbols) { - handleModeChange(keyboardMode, keyboardView, this) + handleModeChange(keyboardMode, this) } } } - /** - * Handles switching between the letter and symbol keyboards. - * - * @param keyboardMode The current keyboard mode (letters or symbols). - * @param keyboardView The instance of the keyboard view. - * @param context The application context. - */ fun handleModeChange( keyboardMode: Int, - keyboardView: KeyboardView?, context: Context, ) { val keyboardXml = @@ -1315,48 +1222,38 @@ abstract class GeneralKeyboardIME( if (this.keyboardMode == keyboardLetters) { val wasShifted = keyboard?.mShiftState == SHIFT_ON_ONE_CHAR || keyboard?.mShiftState == SHIFT_ON_PERMANENT if (wasShifted) { - keyboard?.setShifted(keyboard?.mShiftState ?: SHIFT_OFF) + setShifted(keyboard?.mShiftState ?: SHIFT_OFF) } } - keyboardView?.setKeyboard(keyboard!!) - keyboardView?.invalidateAllKeys() - if (keyboardXml == R.xml.keys_symbols) { - uiManager.setupCurrencySymbol(language) - } } - /** - * Moves the cursor in the input field. - * - * @param moveRight true to move right, false to move left. - */ private fun moveCursor(moveRight: Boolean) { val extractedText = currentInputConnection?.getExtractedText(ExtractedTextRequest(), 0) ?: return val newPos = extractedText.selectionStart + if (moveRight) 1 else -1 currentInputConnection?.setSelection(newPos, newPos) } - /** - * Finds associated emojis for the last typed word. - * - * @param emojiKeywords The map of keywords to emojis. - * @param lastWord The word to look up. - * - * @return A mutable list of emoji suggestions, or null if none are found. - */ fun findEmojisForLastWord( emojiKeywords: HashMap>?, lastWord: String?, ) = lastWord?.let { emojiKeywords?.get(it.lowercase()) } - /** - * Finds the grammatical gender(s) for the last typed word. - * - * @param nounKeywords The map of nouns to their genders. - * @param lastWord The word to look up. - * - * @return A list of gender strings (e.g., "masculine", "neuter"), or null if not a known noun. - */ + fun findEmojisForPrefix( + emojiKeywords: HashMap>?, + prefix: String, + ): MutableList { + if (emojiKeywords.isNullOrEmpty() || prefix.isEmpty()) return mutableListOf() + val needle = prefix.lowercase() + return emojiKeywords.keys + .asSequence() + .filter { it.startsWith(needle) } + .sortedWith(compareBy({ it.length }, { it })) + .flatMap { emojiKeywords.getValue(it).asSequence() } + .distinct() + .take(MAX_COLON_EMOJI_SUGGESTIONS) + .toMutableList() + } + fun findGenderForLastWord( nounKeywords: HashMap>, lastWord: String?, @@ -1371,27 +1268,11 @@ abstract class GeneralKeyboardIME( return null } - /** - * Checks if the last word is a known plural form. - * - * @param pluralWords The set of all known plural words. - * @param lastWord The word to check. - * - * @return true if the word is in the plural set, false otherwise. - */ fun findWhetherWordIsPlural( pluralWords: Set?, lastWord: String?, ): Boolean = pluralWords?.contains(lastWord?.lowercase()) == true - /** - * Finds the next suggestions for the last typed word. - * - * @param wordSuggestions The map of words to their suggestions. - * @param lastWord The word to look up. - * - * @return A list of gender strings (e.g., "masculine", "neuter"), or null if not a known noun. - */ fun getNextWordSuggestions( wordSuggestions: HashMap>, lastWord: String?, @@ -1406,29 +1287,11 @@ abstract class GeneralKeyboardIME( return wordSuggestions[lastWord.lowercase()] } - /** - * Finds the required grammatical case(s) for a preposition. - * - * @param caseAnnotation The map of prepositions to their required cases. - * @param lastWord The word to look up (which should be a preposition). - * - * @return A mutable list of case suggestions (e.g., "accusative case"), or null if not found. - */ fun getCaseAnnotationForPreposition( caseAnnotation: HashMap>, 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. - - /** - * 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. - */ fun updateAutoSuggestText( nounTypeSuggestion: List? = null, isPlural: Boolean = false, @@ -1442,7 +1305,7 @@ abstract class GeneralKeyboardIME( if (currentState != ScribeState.IDLE) { if (currentState != ScribeState.SELECT_COMMAND) { - uiManager.disableAutoSuggest(language) + disableAutoSuggest(language) } return } @@ -1468,42 +1331,18 @@ abstract class GeneralKeyboardIME( else -> false } - if (!handled) uiManager.disableAutoSuggest(language) + if (!handled) 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, - ) + keyboardViewModel.setGenderSuggestions(null, null) 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) @@ -1515,13 +1354,6 @@ abstract class GeneralKeyboardIME( 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) @@ -1533,13 +1365,6 @@ abstract class GeneralKeyboardIME( 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") @@ -1548,15 +1373,6 @@ abstract class GeneralKeyboardIME( 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?, @@ -1577,12 +1393,6 @@ abstract class GeneralKeyboardIME( 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, @@ -1595,48 +1405,9 @@ abstract class GeneralKeyboardIME( 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, - ) + keyboardViewModel.setGenderSuggestions(buttonText, null) } - /** - * 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, @@ -1649,37 +1420,9 @@ abstract class GeneralKeyboardIME( 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, - ) - } + keyboardViewModel.setGenderSuggestions(leftSuggestion.second, rightSuggestion.second) } - /** - * 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?, @@ -1704,13 +1447,6 @@ abstract class GeneralKeyboardIME( } } - /** - * 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 } @@ -1720,16 +1456,10 @@ abstract class GeneralKeyboardIME( } else if (!validCases.isNullOrEmpty()) { handleSingleType(validCases, "preposition") } else { - uiManager.disableAutoSuggest(language) + 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, @@ -1741,70 +1471,48 @@ abstract class GeneralKeyboardIME( .getBaseAutoSuggestions(language) val default1 = baseSuggestions.getOrNull(0) ?: "" val default2 = baseSuggestions.getOrNull(1) ?: "" - setSuggestionButton(uiManager.binding.conjugateBtn, default1) - uiManager.pluralBtn?.let { setSuggestionButton(it, default2) } + keyboardViewModel.setSuggestions(null, default1, default2) } return } + keyboardViewModel.setAutocompleteActive(false) 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) + var sTranslate: String? = null + var sConjugate: String? = suggestion1 + var sPlural: String? = null when { hasLinguisticSuggestions && emojiCount != 0 -> { - uiManager.updateButtonVisibility(currentState, true, autoSuggestEmojis) } - hasLinguisticSuggestions && emojiCount == 0 -> { - setSuggestionButton(uiManager.pluralBtn!!, suggestion2) + sPlural = suggestion2 } !hasLinguisticSuggestions && emojiCount != 0 -> { - setSuggestionButton(uiManager.binding.translateBtn, suggestion2) - uiManager.updateButtonVisibility(currentState, true, autoSuggestEmojis) + sTranslate = suggestion2 } else -> { - setSuggestionButton(uiManager.binding.translateBtn, suggestion2) - setSuggestionButton(uiManager.pluralBtn!!, suggestion3) + sTranslate = suggestion2 + sPlural = 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() - } + keyboardViewModel.setSuggestions(sTranslate, sConjugate, sPlural) } - // MARK: Autocomplete - - /** - * 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) + fun updateAutocompleteSuggestions( + completions: List?, + highlightedSuggestion: String? = null, + ) { + if (currentState != ScribeState.IDLE || completions.isNullOrEmpty()) { + highlightedAutocompleteSuggestion = null + keyboardViewModel.setHighlightedSuggestion(null) + keyboardViewModel.setAutocompleteActive(false) + disableAutoSuggest(language) return } @@ -1812,72 +1520,46 @@ abstract class GeneralKeyboardIME( val completion2 = completions.getOrNull(1) ?: "" val completion3 = completions.getOrNull(2) ?: "" - setAutocompleteButton(uiManager.binding.conjugateBtn, completion1) - setAutocompleteButton(uiManager.binding.translateBtn, completion2) - setAutocompleteButton(uiManager.pluralBtn!!, completion3) + highlightedAutocompleteSuggestion = highlightedSuggestion + keyboardViewModel.setHighlightedSuggestion(highlightedSuggestion) + keyboardViewModel.setAutocompleteActive(true) + keyboardViewModel.setSuggestions(completion1, completion2, completion3) + } - uiManager.binding.separator1.visibility = View.VISIBLE - uiManager.binding.separator2.visibility = View.VISIBLE + private fun replaceCurrentWordWithSuggestion(text: String) { + val ic = currentInputConnection ?: return + val beforeText = ic.getTextBeforeCursor(WORD_LOOKBACK_LENGTH, 0) ?: "" + val wordStartIndex = beforeText.lastIndexOfAny(charArrayOf(' ', '\n', '\t', '.', ',', '?', '!')) + 1 + val currentWord = beforeText.substring(wordStartIndex) + ic.deleteSurroundingText(currentWord.length, 0) + ic.commitText(text, 1) } - /** - * 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 = 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 tryInsertHighlightedAutocompleteSuggestion(): Boolean { + val suggestion = highlightedAutocompleteSuggestion ?: return false + highlightedAutocompleteSuggestion = null + keyboardViewModel.setHighlightedSuggestion(null) + replaceCurrentWordWithSuggestion(suggestion) + currentInputConnection?.commitText(" ", 1) + moveToIdleState() + return true } - /** - * Clears autocomplete suggestions by resetting the suggestion strip - * to the default command buttons via the UI Manager. - */ fun clearAutocomplete() { - if (this::uiManager.isInitialized) { - uiManager.disableAutoSuggest(language) - } + highlightedAutocompleteSuggestion = null + keyboardViewModel.setHighlightedSuggestion(null) + disableAutoSuggest(language) } - /** - * 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. - */ fun returnIsSubsequentRequired(): Boolean = subsequentAreaRequired fun returnSubsequentData(): List> = subsequentData - /** - * Handles a key press on one of the special conjugation keys. - * It either commits the text directly or prepares for a subsequent selection view. - * - * @param code The key code of the pressed key. - * @param isSubsequentRequired true if a sub-view is needed for more options. - * - * @return The label of the key that was pressed. - */ fun handleConjugateKeys( code: Int, isSubsequentRequired: Boolean, ): String? { - val keyLabel = keyboardView?.getKeyLabel(code) + val keyLabel = getKeyLabel(code) if (!isSubsequentRequired) { if (!keyLabel.isNullOrEmpty()) { currentInputConnection?.commitText("$keyLabel ", 1) @@ -1887,12 +1569,6 @@ abstract class GeneralKeyboardIME( return keyLabel } - /** - * Sets up a secondary "sub-view" for conjugation when a single key has multiple options. - * - * @param data The full dataset of subsequent options. - * @param word The specific word selected from the primary view, used to filter the data. - */ fun setupConjugateSubView( data: List>, word: String?, @@ -1904,37 +1580,10 @@ abstract class GeneralKeyboardIME( val prefs = applicationContext.getSharedPreferences("keyboard_preferences", MODE_PRIVATE) prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } val keyboardXmlId = getKeyboardLayoutForState(currentState, true, flattenList.size) - // Re-initialize keyboard via UI manager helper which calls 'initializeKeyboard(xml)'. - uiManager.initializeKeyboard(keyboardXmlId) + subsequentAreaRequired = false prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } - when (flattenList.size) { - DATA_SIZE_2 -> { - keyboardView?.setKeyLabel(flattenList[0], "HI", KeyboardBase.CODE_2X1_TOP) - keyboardView?.setKeyLabel(flattenList[1], "HI", KeyboardBase.CODE_2X1_BOTTOM) - subsequentAreaRequired = false - } + } - DATA_CONSTANT_3 -> { - keyboardView?.setKeyLabel(flattenList[0], "HI", KeyboardBase.CODE_1X3_RIGHT) - keyboardView?.setKeyLabel(flattenList[1], "HI", KeyboardBase.CODE_1X3_CENTER) - keyboardView?.setKeyLabel(flattenList[DATA_SIZE_2], "HI", KeyboardBase.CODE_1X3_RIGHT) - subsequentAreaRequired = false - } - } - prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } - // Binding access via uiManager. - uiManager.binding.ivInfo.visibility = View.GONE - } - - /** - * Determines which keyboard layout XML to use based on the current [ScribeState]. - * - * @param state The current state of the Scribe keyboard. - * @param isSubsequentArea true if this is for a secondary conjugation view. - * @param dataSize The number of items to display, used to select an appropriate layout. - * - * @return The resource ID of the keyboard layout XML. - */ private fun getKeyboardLayoutForState( state: ScribeState, isSubsequentArea: Boolean = false, @@ -1959,94 +1608,180 @@ abstract class GeneralKeyboardIME( } } - /** - * Updates the visibility of the suggestion buttons based on device type (phone/tablet) - * and whether auto-suggestions are currently active. - * - * @param enabled true if suggestions are available. - */ - fun updateButtonVisibility(enabled: Boolean) = uiManager.updateButtonVisibility(currentState, enabled, autoSuggestEmojis) - - /** - * Updates the text of the suggestion buttons, primarily for displaying emoji suggestions. - * - * @param enabled true if suggestions are active. - * @param emojis The list of emojis to display. - */ + fun updateButtonVisibility(enabled: Boolean) { + } + fun updateEmojiSuggestion( enabled: Boolean, emojis: MutableList?, - ) = uiManager.updateEmojiSuggestion(currentState, enabled, emojis) + ) { + if (enabled && emojis != null) { + keyboardViewModel.updateEmojiSuggestions(emojis) + } else { + keyboardViewModel.updateEmojiSuggestions(emptyList()) + } + } + + fun disableAutoSuggest() = disableAutoSuggest(language) - fun disableAutoSuggest() = uiManager.disableAutoSuggest(language) + private fun disableAutoSuggest(language: String) { + keyboardViewModel.setAutocompleteActive(false) + val suggestions = + be.scri.helpers.ui.HintUtils + .getBaseAutoSuggestions(language) + keyboardViewModel.setGenderSuggestions(null, null) + + if (isNumericKeyboardActive) { + keyboardViewModel.setSuggestions(suggestions.getOrNull(0), null, null) + } else { + keyboardViewModel.setSuggestions( + suggestions.getOrNull(0), + suggestions.getOrNull(1), + suggestions.getOrNull(2), + ) + } + } - // MARK: Floating Keyboard Integration + fun onClipboardSuggestionClicked() { + latestClipText?.let { text -> + currentInputConnection?.commitText(text, 1) + } + hasNewClip = false + latestClipText = null + } + + fun openClipboardPanel() { + keyboardViewModel.setClipboardPanelVisible(true) + refreshClipboardItems() + } + + fun closeClipboardPanel() { + keyboardViewModel.setClipboardPanelVisible(false) + } + + private val clipboardRepository by lazy { + be.scri.helpers.clipboard + .ClipboardRepository(this) + } + + private fun refreshClipboardItems() { + imeScope.launch { + val items = clipboardRepository.getAllItems() + keyboardViewModel.updateClipboardItems(items) + } + } + + override fun onClipboardItemClicked(item: be.scri.helpers.clipboard.ClipboardItem) { + currentInputConnection?.commitText(item.text, 1) + closeClipboardPanel() + } + + override fun onClipboardItemDelete(item: be.scri.helpers.clipboard.ClipboardItem) { + imeScope.launch { + clipboardRepository.deleteItem(item.id) + refreshClipboardItems() + } + } + + override fun onClipboardItemPinToggle(item: be.scri.helpers.clipboard.ClipboardItem) { + imeScope.launch { + clipboardRepository.togglePin(item.id, item.isPinned) + refreshClipboardItems() + } + } - override fun getKeyboardWidth(): Int = + override fun onClipboardClearAll() { + imeScope.launch { + clipboardRepository.clearAll() + refreshClipboardItems() + } + } + + override fun onClipboardPanelClose() { + closeClipboardPanel() + } + + fun getKeyboardWidth(): Int = if (isFloatingMode) { val density = resources.displayMetrics.density val screenWidth = resources.displayMetrics.widthPixels val floatWidth = (320f * density).toInt() - Math.min(floatWidth, (screenWidth * 0.85f).toInt()) + minOf(floatWidth, (screenWidth * 0.85f).toInt()) } else { resources.displayMetrics.widthPixels } private fun recreateKeyboard() { - if (!this::uiManager.isInitialized) return val xmlId = getCurrentKeyboardLayoutXML() val currentShiftState = keyboard?.mShiftState ?: SHIFT_OFF keyboard = KeyboardBase(this, xmlId, enterKeyType, getKeyboardWidth()) keyboard?.setShifted(currentShiftState) - keyboardView?.setKeyboard(keyboard!!) if (xmlId == R.xml.keys_symbols) { - uiManager.setupCurrencySymbol(language) + keyboardViewModel.setCurrencySymbol(PreferencesHelper.getDefaultCurrencySymbol(this, language)) } - keyboardView?.invalidateAllKeys() } val isFloatingMode: Boolean - get() = floatingKeyboardHandler.isFloatingMode + get() = keyboardViewModel.isFloatingMode.value + + private fun loadFloatingTransform() { + keyboardViewModel.setFloatingTransform( + PreferencesHelper.getFloatingX(this, language), + PreferencesHelper.getFloatingY(this, language), + PreferencesHelper.getFloatingScaleX(this, language), + PreferencesHelper.getFloatingScaleY(this, language), + ) + } fun initFloatingMode() { - floatingKeyboardHandler.initFloatingMode() + keyboardViewModel.setFloatingMode(PreferencesHelper.getIsFloatingModeEnabled(this, language)) + loadFloatingTransform() + applyFloatingModeState() } fun toggleFloatingMode() { - floatingKeyboardHandler.toggleFloatingMode() + val enabled = !isFloatingMode + PreferencesHelper.setIsFloatingModeEnabled(this, language, enabled) + keyboardViewModel.setFloatingMode(enabled) + loadFloatingTransform() + applyFloatingModeState() } fun disableFloatingMode() { - floatingKeyboardHandler.disableFloatingMode() + if (!isFloatingMode) return + PreferencesHelper.setIsFloatingModeEnabled(this, language, false) + keyboardViewModel.setFloatingMode(false) + applyFloatingModeState() } fun applyFloatingModeState() { - floatingKeyboardHandler.applyFloatingModeState() - } - - fun setupFloatingDragListener() { - floatingKeyboardHandler.setupFloatingDragListener() - } - - fun disableParentClipping(view: View) { - floatingKeyboardHandler.disableParentClipping(view) - } - - override fun onClipboardSuggestionClicked() { - clipboardHandler.onClipboardSuggestionClicked() - } - - fun hideClipboardSuggestionChip() { - clipboardHandler.hideClipboardSuggestionChip() - } - - fun openClipboardPanel() { - clipboardHandler.openClipboardPanel() + setBackDisposition( + if (isFloatingMode) { + BACK_DISPOSITION_ADJUST_NOTHING + } else { + BACK_DISPOSITION_DEFAULT + }, + ) + recreateKeyboard() + applyNavBarColor() } - fun closeClipboardPanel() { - clipboardHandler.closeClipboardPanel() + override fun onFloatingGestureEnded( + offsetX: Float, + offsetY: Float, + scaleX: Float, + scaleY: Float, + dockToBottom: Boolean, + ) { + if (dockToBottom) { + disableFloatingMode() + return + } + PreferencesHelper.setFloatingX(this, language, offsetX) + PreferencesHelper.setFloatingY(this, language, offsetY) + PreferencesHelper.setFloatingScaleX(this, language, scaleX) + PreferencesHelper.setFloatingScaleY(this, language, scaleY) } } diff --git a/app/src/keyboards/java/be/scri/services/GermanKeyboardIME.kt b/app/src/keyboards/java/be/scri/services/GermanKeyboardIME.kt index ff9a670bb..e4249b797 100644 --- a/app/src/keyboards/java/be/scri/services/GermanKeyboardIME.kt +++ b/app/src/keyboards/java/be/scri/services/GermanKeyboardIME.kt @@ -45,11 +45,6 @@ class GermanKeyboardIME : GeneralKeyboardIME(ScribeLanguage.GERMAN) { override var switchToLetters: Boolean = false override var hasTextBeforeCursor: Boolean = false - // REFACTOR_FIX: The 'binding' and 'keyboardView' properties are no longer abstract in the parent class, - // so we must remove the overrides here. They are now inherited directly. - // override lateinit var binding: KeyboardViewCommandOptionsBinding // REMOVED - // override var keyboardView: KeyboardView? = null // REMOVED - private val keyHandler by lazy { KeyHandler(this) } override fun onKey(code: Int) { diff --git a/app/src/keyboards/java/be/scri/ui/compose/ClipboardPanel.kt b/app/src/keyboards/java/be/scri/ui/compose/ClipboardPanel.kt new file mode 100644 index 000000000..61744df9f --- /dev/null +++ b/app/src/keyboards/java/be/scri/ui/compose/ClipboardPanel.kt @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import be.scri.R +import be.scri.helpers.clipboard.ClipboardItem + +@Composable +fun ClipboardPanel( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val items by viewModel.clipboardItems.collectAsState() + val keyboard by viewModel.keyboard.collectAsState() + + val density = androidx.compose.ui.platform.LocalDensity.current + val contentHeightDp = keyboard?.let { with(density) { it.mHeight.toDp() } } ?: 250.dp + + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val panelBg = if (isDarkMode) Color(0xFF1E1E1E) else Color(0xFFD3D6DD) + val textColor = if (isDarkMode) Color.White else Color.Black + val iconTint = if (isDarkMode) Color.White else Color.Black + + Column( + modifier = + modifier + .fillMaxWidth() + .background(panelBg), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(46.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_left_vector), + contentDescription = "Close Clipboard", + tint = iconTint, + modifier = + Modifier + .padding(start = 8.dp) + .size(32.dp) + .clickable { actionListener.onClipboardPanelClose() } + .padding(4.dp), + ) + Text( + text = "Clipboard", + color = textColor, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .weight(1f) + .padding(start = 8.dp), + ) + Icon( + painter = painterResource(id = R.drawable.ic_delete_vector), + contentDescription = "Clear All", + tint = Color(0xFFE53935), + modifier = + Modifier + .padding(end = 8.dp) + .size(32.dp) + .clickable { actionListener.onClipboardClearAll() } + .padding(4.dp), + ) + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(contentHeightDp) + .background(panelBg), + ) { + if (items.isEmpty()) { + Text( + text = "Clipboard is empty", + color = textColor, + fontSize = 16.sp, + modifier = Modifier.align(Alignment.Center), + ) + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = + Modifier + .fillMaxSize() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items, key = { it.id }) { item -> + ClipboardItemCard( + item = item, + isDarkMode = isDarkMode, + onClick = { actionListener.onClipboardItemClicked(item) }, + onPinToggle = { actionListener.onClipboardItemPinToggle(item) }, + onDelete = { actionListener.onClipboardItemDelete(item) }, + ) + } + } + } + } + } +} + +@Composable +private fun ClipboardItemCard( + item: ClipboardItem, + isDarkMode: Boolean, + onClick: () -> Unit, + onPinToggle: () -> Unit, + onDelete: () -> Unit, +) { + val cardBg = if (isDarkMode) Color.Black else Color.White + val textColor = if (isDarkMode) Color.White else Color.Black + val labelColor = Color(0xFF999999) + + var menuExpanded by remember { mutableStateOf(false) } + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(76.dp) + .background(cardBg, RoundedCornerShape(10.dp)) + .border(1.dp, Color(0x20000000), RoundedCornerShape(10.dp)) + .pointerInput(item.id) { + detectTapGestures( + onTap = { onClick() }, + onLongPress = { menuExpanded = true }, + ) + }.padding(8.dp), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = if (item.isPinned) "Pinned" else "Copied text", + color = labelColor, + fontSize = 11.sp, + ) + Icon( + painter = painterResource(id = R.drawable.ic_copy_vector), + contentDescription = null, + tint = labelColor, + modifier = Modifier.size(14.dp), + ) + } + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = item.text, + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(if (item.isPinned) "Unpin" else "Pin") }, + onClick = { + menuExpanded = false + onPinToggle() + }, + ) + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + menuExpanded = false + onDelete() + }, + ) + } + } +} diff --git a/app/src/keyboards/java/be/scri/ui/compose/ComposeKeyboardView.kt b/app/src/keyboards/java/be/scri/ui/compose/ComposeKeyboardView.kt new file mode 100644 index 000000000..08a2c4e9a --- /dev/null +++ b/app/src/keyboards/java/be/scri/ui/compose/ComposeKeyboardView.kt @@ -0,0 +1,594 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import be.scri.R +import be.scri.helpers.KeyboardBase +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.Locale + +private val ALT_POPUP_ITEM_WIDTH = 42.dp +private val ALT_POPUP_ITEM_HEIGHT = 44.dp +private val ALT_POPUP_H_PADDING = 6.dp +private val ALT_POPUP_V_PADDING = 3.dp +private val ALT_POPUP_ITEM_GAP = 4.dp +private val ALT_POPUP_OFFSET_Y = 58.dp +private val EMOJI_COG_SIZE = 9.dp +private val EMOJI_COG_END_PADDING = 2.dp +private val EMOJI_COG_TOP_PADDING = 2.dp +private val EMOJI_ICON_SIZE = 18.dp +private val EMOJI_ICON_OFFSET_Y = 3.dp +private val KEY_ICON_SIZE = 22.dp + +internal data class EmojiKeyOption( + val code: Int, + val iconRes: Int, + val description: String, +) + +internal val EMOJI_KEY_OPTIONS = + listOf( + EmojiKeyOption(KeyboardBase.KEYCODE_FLOAT_TOGGLE, R.drawable.ic_float_keyboard, "Floating keyboard"), + EmojiKeyOption(KeyboardBase.KEYCODE_EMOJI, R.drawable.ic_emoji_vector, "Emoji palette"), + EmojiKeyOption(KeyboardBase.KEYCODE_CLIPBOARD, R.drawable.ic_clipboard_vector, "Clipboard"), + ) + +internal fun isEmojiKey(key: KeyboardBase.Key): Boolean = key.code == KeyboardBase.KEYCODE_EMOJI + +internal fun hasLongPressOptions(key: KeyboardBase.Key): Boolean = isEmojiKey(key) || !key.popupCharacters.isNullOrEmpty() || key.topSmallNumber.isNotEmpty() + +internal fun longPressOptionCount(key: KeyboardBase.Key): Int = if (isEmojiKey(key)) EMOJI_KEY_OPTIONS.size else altCharactersFor(key).size + +internal fun altCharactersFor(key: KeyboardBase.Key): List { + val popupChars = key.popupCharacters?.toString() ?: "" + val smallNumber = key.topSmallNumber ?: "" + val list = popupChars.map { it.toString() }.toMutableList() + if (smallNumber.isNotEmpty() && !list.contains(smallNumber)) { + list.add(0, smallNumber) + } + return list +} + +private fun altPopupLeft( + key: KeyboardBase.Key, + count: Int, + itemWidthPx: Float, + horizontalPaddingPx: Float, + keyboardWidthPx: Float, +): Float { + val totalWidth = count * itemWidthPx + horizontalPaddingPx * 2f + val raw = key.x + (key.width - totalWidth) / 2f + return raw.coerceIn(0f, (keyboardWidthPx - totalWidth).coerceAtLeast(0f)) +} + +@Composable +fun ComposeKeyboardView( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val keyboard by viewModel.keyboard.collectAsState() + val shiftState by viewModel.shiftState.collectAsState() + val currentState by viewModel.currentState.collectAsState() + + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val density = androidx.compose.ui.platform.LocalDensity.current + + val keyboardBgColor = if (isDarkMode) Color(0xFF2C2C2E) else Color(0xFFD1D4DB) + + var pressedKey by remember { mutableStateOf(null) } + var activeLongPressKey by remember { mutableStateOf(null) } + var hoveredAltIndex by remember { mutableStateOf(-1) } + + if (keyboard != null) { + val kb = keyboard!! + val kbHeightDp = with(density) { kb.mHeight.toDp() } + + Box( + modifier = + modifier + .fillMaxWidth() + .height(kbHeightDp) + .background(keyboardBgColor) + .pointerInput(kb) { + val altItemWidthPx = ALT_POPUP_ITEM_WIDTH.toPx() + val altPaddingPx = ALT_POPUP_H_PADDING.toPx() + val keyboardWidthPx = kb.mMinWidth.toFloat() + + kotlinx.coroutines.coroutineScope { + val scope = this + awaitEachGesture { + val downEvent = awaitFirstDown() + var currentKey = kb.mKeys?.find { it?.isInside(downEvent.position.x.toInt(), downEvent.position.y.toInt()) == true } + + pressedKey = currentKey + activeLongPressKey = null + hoveredAltIndex = -1 + + if (currentKey != null) { + actionListener.onPress(currentKey.code) + } + + val repeatJob = + if (currentKey?.code == KeyboardBase.KEYCODE_DELETE) { + scope.launch { + delay(400) + while (isActive) { + actionListener.onKey(KeyboardBase.KEYCODE_DELETE) + delay(50) + } + } + } else { + null + } + + var longPressJob: kotlinx.coroutines.Job? = null + if (currentKey != null && hasLongPressOptions(currentKey)) { + longPressJob = + scope.launch { + delay(500) + activeLongPressKey = currentKey + hoveredAltIndex = 0 + } + } + + do { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull() ?: continue + val touchX = change.position.x.toInt() + val touchY = change.position.y.toInt() + + if (activeLongPressKey != null) { + val key = activeLongPressKey!! + val optionCount = longPressOptionCount(key) + + if (optionCount > 0) { + val contentLeft = + altPopupLeft( + key, + optionCount, + altItemWidthPx, + altPaddingPx, + keyboardWidthPx, + ) + altPaddingPx + hoveredAltIndex = + ((touchX - contentLeft) / altItemWidthPx) + .toInt() + .coerceIn(0, optionCount - 1) + } + } else { + val newKey = kb.mKeys?.find { it?.isInside(touchX, touchY) == true } + + if (newKey != currentKey) { + repeatJob?.cancel() + longPressJob?.cancel() + if (currentKey != null) { + actionListener.onActionUp() + } + currentKey = newKey + pressedKey = newKey + if (currentKey != null) { + actionListener.onPress(currentKey.code) + if (hasLongPressOptions(currentKey)) { + longPressJob = + scope.launch { + delay(500) + activeLongPressKey = currentKey + hoveredAltIndex = 0 + } + } + } + } + } + } while (event.changes.any { it.pressed }) + + repeatJob?.cancel() + longPressJob?.cancel() + + if (activeLongPressKey != null) { + val key = activeLongPressKey!! + + if (isEmojiKey(key)) { + val option = EMOJI_KEY_OPTIONS.getOrNull(hoveredAltIndex) + actionListener.onKey(option?.code ?: key.code) + } else { + val altList = altCharactersFor(key) + if (hoveredAltIndex in altList.indices) { + val selectedChar = altList[hoveredAltIndex] + if (selectedChar.length == 1) { + actionListener.onKey(selectedChar[0].code) + } else { + actionListener.onText(selectedChar) + } + } else { + actionListener.onKey(key.code) + } + } + actionListener.onActionUp() + } else if (currentKey != null) { + actionListener.onKey(currentKey.code) + actionListener.onActionUp() + } + + pressedKey = null + activeLongPressKey = null + hoveredAltIndex = -1 + } + } + }, + ) { + kb.mKeys?.forEachIndexed { index, key -> + if (key != null) { + val isPressed = pressedKey == key + androidx.compose.runtime.key(index) { + KeyboardKey( + key = key, + shiftState = shiftState, + isDarkMode = isDarkMode, + isPressed = isPressed, + currentState = currentState, + ) + } + } + } + + if (pressedKey != null && activeLongPressKey == null && !isSpecialKey(pressedKey!!)) { + val key = pressedKey!! + val keyLabel = adjustCase(key.label, shiftState) ?: "" + if (keyLabel.isNotEmpty()) { + val keyWidthDp = with(density) { key.width.toDp() } + val keyHeightDp = with(density) { key.height.toDp() } + val keyXDp = with(density) { key.x.toDp() } + val keyYDp = with(density) { key.y.toDp() } + val kbWidthDp = with(density) { kb.mMinWidth.toDp() } + val previewXDp = (keyXDp + (keyWidthDp - 48.dp) / 2).coerceIn(0.dp, kbWidthDp - 48.dp) + + Box( + modifier = + Modifier + .offset(x = previewXDp, y = keyYDp - 48.dp) + .size(48.dp) + .shadow( + elevation = 6.dp, + shape = CircleShape, + clip = false, + ).background( + color = if (isDarkMode) Color(0xFF555558) else Color.White, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = keyLabel, + color = if (isDarkMode) Color.White else Color.Black, + fontSize = 24.sp, + fontWeight = FontWeight.Normal, + ) + } + } + } + + if (activeLongPressKey != null) { + val key = activeLongPressKey!! + val isEmojiOptions = isEmojiKey(key) + val altList = remember(activeLongPressKey) { if (isEmojiOptions) emptyList() else altCharactersFor(key) } + val optionCount = if (isEmojiOptions) EMOJI_KEY_OPTIONS.size else altList.size + + if (optionCount > 0) { + val keyYDp = with(density) { key.y.toDp() } + val popupXDp = + with(density) { + altPopupLeft( + key, + optionCount, + ALT_POPUP_ITEM_WIDTH.toPx(), + ALT_POPUP_H_PADDING.toPx(), + kb.mMinWidth.toFloat(), + ).toDp() + } + + Box( + modifier = + Modifier + .offset(x = popupXDp, y = keyYDp - ALT_POPUP_OFFSET_Y) + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(24.dp), + clip = false, + ).background( + color = if (isDarkMode) Color(0xFF3A3A3C) else Color(0xFFF0F0F0), + shape = RoundedCornerShape(24.dp), + ).padding(horizontal = ALT_POPUP_H_PADDING, vertical = ALT_POPUP_V_PADDING), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(ALT_POPUP_ITEM_GAP)) { + repeat(optionCount) { index -> + val isHovered = hoveredAltIndex == index + Box( + modifier = + Modifier + .size( + width = ALT_POPUP_ITEM_WIDTH - ALT_POPUP_ITEM_GAP, + height = ALT_POPUP_ITEM_HEIGHT, + ).background( + color = if (isHovered) (if (isDarkMode) Color(0xFF636366) else Color(0xFFD0D0D0)) else Color.Transparent, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (isEmojiOptions) { + val option = EMOJI_KEY_OPTIONS[index] + Icon( + painter = painterResource(id = option.iconRes), + contentDescription = option.description, + tint = if (isDarkMode) Color.White else Color.Black, + modifier = Modifier.size(KEY_ICON_SIZE), + ) + } else { + Text( + text = altList[index], + color = if (isDarkMode) Color.White else Color.Black, + fontSize = 20.sp, + fontWeight = FontWeight.Normal, + ) + } + } + } + } + } + } + } + } + } else { + Box( + modifier = Modifier.fillMaxWidth().height(250.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = "Loading keyboard...", color = if (isDarkMode) Color.White else Color.Black) + } + } +} + +@Composable +fun KeyboardKey( + key: KeyboardBase.Key, + shiftState: Int, + isDarkMode: Boolean, + isPressed: Boolean, + currentState: be.scri.models.ScribeState, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + + val xDp = with(density) { key.x.toDp() } + val yDp = with(density) { key.y.toDp() } + val widthDp = with(density) { key.width.toDp() } + val heightDp = with(density) { key.height.toDp() } + + val isSpecial = isSpecialKey(key) + + val pressedKeyBg = if (isDarkMode) Color(0xFF5A5A5E) else Color(0xFFCDCDD2) + val pressedSpecialKeyBg = if (isDarkMode) Color(0xFF48484A) else Color(0xFF9D9DA3) + + val keyBgColor = if (isDarkMode) Color(0xFF4A4A4E) else Color(0xFFFFFFFF) + val specialKeyBgColor = if (isDarkMode) Color(0xFF3A3A3C) else Color(0xFFACB2BF) + + val bg = + if (isPressed) { + if (isSpecial) pressedSpecialKeyBg else pressedKeyBg + } else { + if (isSpecial) specialKeyBgColor else keyBgColor + } + val textColor = if (isDarkMode) Color.White else Color.Black + + val label = adjustCase(key.label, shiftState) + + val shadowColor = Color.Black.copy(alpha = 100f / 255f) + + Box( + modifier = + modifier + .offset(x = xDp, y = yDp) + .size(width = widthDp, height = heightDp) + .padding(horizontal = 3.dp, vertical = 4.dp) + .drawBehind { + val radius = 5.dp.toPx() + val shadowOffsetY = 3.dp.toPx() + drawRoundRect( + color = shadowColor, + topLeft = + androidx.compose.ui.geometry + .Offset(0f, shadowOffsetY), + size = size, + cornerRadius = + androidx.compose.ui.geometry + .CornerRadius(radius, radius), + ) + }.background(bg, shape = RoundedCornerShape(5.dp)), + contentAlignment = Alignment.Center, + ) { + if (key.code == KeyboardBase.KEYCODE_EMOJI) { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(end = EMOJI_COG_END_PADDING, top = EMOJI_COG_TOP_PADDING), + contentAlignment = Alignment.TopEnd, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_settings_cog_vector), + contentDescription = null, + tint = textColor, + modifier = Modifier.size(EMOJI_COG_SIZE), + ) + } + } + + val smallNumber = key.topSmallNumber + if (!smallNumber.isNullOrEmpty() && !isSpecial) { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(end = 4.dp, top = 2.dp), + contentAlignment = Alignment.TopEnd, + ) { + Text( + text = smallNumber, + color = if (isDarkMode) Color(0xFF8E8E93) else Color(0xFF8E8E93), + fontSize = 10.sp, + ) + } + } + + val iconRes = + when (key.code) { + KeyboardBase.KEYCODE_SHIFT -> { + when (shiftState) { + 0 -> R.drawable.ic_caps_outline_vector + 1 -> R.drawable.ic_caps_vector + 2 -> R.drawable.ic_caps_underlined_vector + else -> R.drawable.ic_caps_outline_vector + } + } + KeyboardBase.KEYCODE_CAPS_LOCK -> { + if (shiftState == 2) R.drawable.ic_caps_lock_on else R.drawable.ic_caps_lock_off + } + KeyboardBase.KEYCODE_DELETE -> R.drawable.ic_clear_outline_vector + KeyboardBase.KEYCODE_LEFT_ARROW -> R.drawable.ic_left_arrow + KeyboardBase.KEYCODE_RIGHT_ARROW -> R.drawable.ic_right_arrow + KeyboardBase.KEYCODE_CLIPBOARD -> R.drawable.ic_clipboard_vector + KeyboardBase.KEYCODE_ENTER -> { + if (currentState == be.scri.models.ScribeState.TRANSLATE || + currentState == be.scri.models.ScribeState.CONJUGATE || + currentState == be.scri.models.ScribeState.PLURAL + ) { + R.drawable.play_button + } else { + null + } + } + else -> null + } + + val context = LocalContext.current + val hasVectorOrRaster = + remember(iconRes) { + if (iconRes == null) { + false + } else { + try { + val drawable = + androidx.core.content.ContextCompat + .getDrawable(context, iconRes) + drawable is android.graphics.drawable.VectorDrawable || + drawable is androidx.vectordrawable.graphics.drawable.VectorDrawableCompat || + drawable is android.graphics.drawable.BitmapDrawable + } catch (e: Exception) { + false + } + } + } + + val isEmojiKey = key.code == KeyboardBase.KEYCODE_EMOJI + val iconModifier = + if (isEmojiKey) { + Modifier.offset(y = EMOJI_ICON_OFFSET_Y).size(EMOJI_ICON_SIZE) + } else { + Modifier.size(KEY_ICON_SIZE) + } + + if (iconRes != null && hasVectorOrRaster) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = textColor, + modifier = iconModifier, + ) + } else if (key.icon != null) { + Canvas(modifier = iconModifier) { + drawIntoCanvas { canvas -> + key.icon?.let { drawable -> + drawable.setBounds(0, 0, size.width.toInt(), size.height.toInt()) + drawable.setTint(textColor.toArgb()) + drawable.draw(canvas.nativeCanvas) + } + } + } + } else if (label != null && label.isNotEmpty()) { + Text( + text = label, + color = textColor, + fontSize = if (label.length > 1) 14.sp else 22.sp, + fontWeight = FontWeight.Light, + ) + } + } +} + +private fun isSpecialKey(key: KeyboardBase.Key): Boolean = + key.code in + listOf( + KeyboardBase.KEYCODE_SHIFT, + KeyboardBase.KEYCODE_MODE_CHANGE, + KeyboardBase.KEYCODE_DELETE, + KeyboardBase.KEYCODE_TAB, + KeyboardBase.KEYCODE_CAPS_LOCK, + KeyboardBase.KEYCODE_LEFT_ARROW, + KeyboardBase.KEYCODE_RIGHT_ARROW, + KeyboardBase.KEYCODE_CLIPBOARD, + ) + +private fun adjustCase( + label: CharSequence?, + shiftState: Int, +): String? { + if (label == null) return null + val labelStr = label.toString() + if (labelStr == "tab" || labelStr == "caps lock") return labelStr + return if (shiftState == 1 || shiftState == 2) { + labelStr.uppercase(Locale.getDefault()) + } else { + labelStr + } +} diff --git a/app/src/keyboards/java/be/scri/ui/compose/ConjugateGrid.kt b/app/src/keyboards/java/be/scri/ui/compose/ConjugateGrid.kt new file mode 100644 index 000000000..c9a2a986e --- /dev/null +++ b/app/src/keyboards/java/be/scri/ui/compose/ConjugateGrid.kt @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun ConjugateGrid( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val conjugateOutput by viewModel.conjugateOutput.collectAsState() + val selectedCategory by viewModel.selectedConjugationSubCategory.collectAsState() + + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val bgColor = if (isDarkMode) Color(0xFF282828) else Color(0xFFEBEBEB) + val cardBg = if (isDarkMode) Color(0xFF404040) else Color.White + val textColor = if (isDarkMode) Color.White else Color.Black + + val title = conjugateOutput?.keys?.firstOrNull() + val languageOutput = title?.let { conjugateOutput!![it] } + + val isSubSelection = selectedCategory != null + val showCategories = !isSubSelection && (languageOutput?.containsKey(title) != true) + + val forms = + if (isSubSelection) { + languageOutput?.get(selectedCategory)?.toList() ?: emptyList() + } else if (showCategories) { + languageOutput?.map { (_, values) -> + if (values.size == 1) values.first() else values.joinToString(" / ") + } ?: emptyList() + } else { + languageOutput?.get(title)?.toList() ?: emptyList() + } + + val columns = if (isSubSelection || (forms.size <= 2)) 1 else 2 + + Box( + modifier = + modifier + .fillMaxWidth() + .height(250.dp) // Standard keyboard height approx + .background(bgColor) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val chunkedForms = forms.chunked(columns) + for (rowForms in chunkedForms) { + Row( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (form in rowForms) { + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .background(cardBg, RoundedCornerShape(8.dp)) + .clickable { + if (form.isNotEmpty()) { + actionListener.onSuggestionClicked(form) + } + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = form, + color = textColor, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(4.dp), + ) + } + } + // Fill remaining space if odd number of items in a row + if (rowForms.size < columns) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } +} diff --git a/app/src/keyboards/java/be/scri/ui/compose/EmojiKeyboardPanel.kt b/app/src/keyboards/java/be/scri/ui/compose/EmojiKeyboardPanel.kt new file mode 100644 index 000000000..ba5be7821 --- /dev/null +++ b/app/src/keyboards/java/be/scri/ui/compose/EmojiKeyboardPanel.kt @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import be.scri.R +import be.scri.helpers.EMOJI_SPEC_FILE_PATH +import be.scri.helpers.EmojiData +import be.scri.helpers.KeyboardBase +import be.scri.helpers.KeyboardLanguageMappingConstants +import be.scri.helpers.LanguageMappingConstants.getLanguageAlias +import be.scri.helpers.getCategoryIconRes +import be.scri.helpers.parseRawEmojiSpecsFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private sealed interface EmojiListItem { + data class Category( + val name: String, + ) : EmojiListItem + + data class Emoji( + val data: EmojiData, + ) : EmojiListItem +} + +@Composable +fun EmojiKeyboardPanel( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val language by viewModel.language.collectAsState() + val keyboard by viewModel.keyboard.collectAsState() + + val density = androidx.compose.ui.platform.LocalDensity.current + val contentHeightDp = keyboard?.let { with(density) { it.mHeight.toDp() } } ?: 250.dp + + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val bgColor = if (isDarkMode) Color(0xFF2C2C2E) else Color(0xFFD1D4DB) + val iconTint = if (isDarkMode) Color.White else Color.Black + val activeColor = + if (isDarkMode) { + androidx.compose.ui.graphics + .Color(0xFF66B2FF) + } else { + androidx.compose.ui.graphics + .Color(0xFF0066CC) + } + val inactiveColor = Color(0xFF9E9E9E) + + val context = LocalContext.current + var categories by remember { mutableStateOf>>(emptyMap()) } + + LaunchedEffect(Unit) { + val loaded = + withContext(Dispatchers.IO) { + val fullEmojiList = parseRawEmojiSpecsFile(context, EMOJI_SPEC_FILE_PATH) + val systemFontPaint = + android.graphics.Paint().apply { + typeface = android.graphics.Typeface.DEFAULT + } + fullEmojiList + .filter { emoji -> systemFontPaint.hasGlyph(emoji.emoji) } + .groupBy { it.category } + } + categories = loaded + } + + val items = + remember(categories) { + val list = mutableListOf() + categories.forEach { (category, emojis) -> + list.add(EmojiListItem.Category(category)) + emojis.forEach { list.add(EmojiListItem.Emoji(it)) } + } + list + } + + val categoryHeaders = + remember(language) { + (KeyboardLanguageMappingConstants.emojiCategoryHeaders["EN"] ?: emptyMap()) + + (KeyboardLanguageMappingConstants.emojiCategoryHeaders[getLanguageAlias(language)] ?: emptyMap()) + } + + val gridState = rememberLazyGridState() + val coroutineScope = rememberCoroutineScope() + var activeCategoryIndex by remember { mutableIntStateOf(0) } + + Column( + modifier = + modifier + .fillMaxWidth() + .height(contentHeightDp) + .background(bgColor), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(44.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = R.drawable.close_icon), + contentDescription = "Close emoji keyboard", + colorFilter = ColorFilter.tint(iconTint), + modifier = + Modifier + .size(40.dp) + .clickable { viewModel.setEmojiKeyboardVisible(false) } + .padding(10.dp), + ) + + Text( + text = "ABC", + color = iconTint, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + modifier = + Modifier + .weight(1f) + .padding(start = 8.dp) + .clickable { viewModel.setEmojiKeyboardVisible(false) }, + ) + + Image( + painter = painterResource(id = R.drawable.emoji_backspace), + contentDescription = "Delete", + colorFilter = ColorFilter.tint(iconTint), + modifier = + Modifier + .size(40.dp) + .clickable { actionListener.onKey(KeyboardBase.KEYCODE_DELETE) } + .padding(8.dp), + ) + } + + Box(modifier = Modifier.weight(1f)) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 44.dp), + state = gridState, + modifier = Modifier.fillMaxSize(), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(horizontal = 4.dp), + ) { + items( + count = items.size, + span = { index -> + if (items[index] is EmojiListItem.Category) { + GridItemSpan(maxLineSpan) + } else { + GridItemSpan(1) + } + }, + ) { index -> + when (val item = items[index]) { + is EmojiListItem.Category -> { + Text( + text = categoryHeaders[item.name] ?: item.name, + color = iconTint, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 4.dp), + ) + } + is EmojiListItem.Emoji -> { + Box( + modifier = + Modifier + .size(44.dp) + .clickable { actionListener.onEmojiSelected(item.data.emoji) }, + contentAlignment = Alignment.Center, + ) { + Text(text = item.data.emoji, fontSize = 24.sp) + } + } + } + } + } + } + + if (categories.isNotEmpty()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(40.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + categories.keys.toList().forEachIndexed { index, category -> + Image( + painter = painterResource(id = getCategoryIconRes(category)), + contentDescription = category, + colorFilter = ColorFilter.tint(if (index == activeCategoryIndex) activeColor else inactiveColor), + modifier = + Modifier + .weight(1f) + .size(22.dp) + .clickable { + activeCategoryIndex = index + val position = items.indexOfFirst { it is EmojiListItem.Category && it.name == category } + if (position != -1) { + coroutineScope.launch { + gridState.scrollToItem(position) + } + } + }, + ) + } + } + } + } +} diff --git a/app/src/keyboards/java/be/scri/ui/compose/FloatingKeyboardChrome.kt b/app/src/keyboards/java/be/scri/ui/compose/FloatingKeyboardChrome.kt new file mode 100644 index 000000000..efb5e27c9 --- /dev/null +++ b/app/src/keyboards/java/be/scri/ui/compose/FloatingKeyboardChrome.kt @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp + +private const val MIN_SCALE = 0.6f +private const val MAX_SCALE = 1.5f +private const val DOCK_THRESHOLD_DP = 60f + +private val CARD_CORNER = 16.dp +private val CARD_BOTTOM_MARGIN = 12.dp +private val HANDLE_STRIP_HEIGHT = 24.dp +private val RESIZE_TOUCH_SIZE = 24.dp +private val RESIZE_DOT_SIZE = 10.dp + +private const val FLOAT_AREA_SCREEN_FRACTION = 0.72f + +@Composable +fun FloatingKeyboardChrome( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val offsetX by viewModel.floatingOffsetX.collectAsState() + val offsetY by viewModel.floatingOffsetY.collectAsState() + val scaleX by viewModel.floatingScaleX.collectAsState() + val scaleY by viewModel.floatingScaleY.collectAsState() + val keyboard by viewModel.keyboard.collectAsState() + + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val density = LocalDensity.current + val dockThresholdPx = with(density) { DOCK_THRESHOLD_DP.dp.toPx() } + val cardColor = if (isDarkMode) Color(0xFF2C2C2E) else Color(0xFFD1D4DB) + val handleColor = if (isDarkMode) Color(0x4DFFFFFF) else Color(0x40000000) + val cornerColor = if (isDarkMode) Color(0xFFAEB3BE) else Color(0xFF4B4B4B) + + val cardWidthDp = keyboard?.mMinWidth?.takeIf { it > 0 }?.let { with(density) { it.toDp() } } + + var liveOffsetX by remember(offsetX) { mutableFloatStateOf(offsetX) } + var liveOffsetY by remember(offsetY) { mutableFloatStateOf(offsetY) } + var liveScaleX by remember(scaleX) { mutableFloatStateOf(scaleX) } + var liveScaleY by remember(scaleY) { mutableFloatStateOf(scaleY) } + var cardSize by remember { mutableStateOf(Size.Zero) } + var areaSize by remember { mutableStateOf(Size.Zero) } + + val floatAreaHeight = (LocalConfiguration.current.screenHeightDp * FLOAT_AREA_SCREEN_FRACTION).dp + + fun clampOffsetX(value: Float): Float { + if (areaSize.width <= 0f || cardSize.width <= 0f) return value + val limit = ((areaSize.width - cardSize.width) / 2f).coerceAtLeast(0f) + return value.coerceIn(-limit, limit) + } + + fun clampOffsetY(value: Float): Float { + if (areaSize.height <= 0f || cardSize.height <= 0f) return value + val minimum = -(areaSize.height - cardSize.height).coerceAtLeast(0f) + return value.coerceIn(minimum, dockThresholdPx * 1.5f) + } + + fun persist(dock: Boolean) { + viewModel.setFloatingTransform(liveOffsetX, liveOffsetY, liveScaleX, liveScaleY) + actionListener.onFloatingGestureEnded(liveOffsetX, liveOffsetY, liveScaleX, liveScaleY, dock) + } + + Box( + modifier = + modifier + .fillMaxWidth() + .height(floatAreaHeight) + .padding(bottom = CARD_BOTTOM_MARGIN) + .onGloballyPositioned { coords -> + areaSize = Size(coords.size.width.toFloat(), coords.size.height.toFloat()) + }, + ) { + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .then(if (cardWidthDp != null) Modifier.width(cardWidthDp) else Modifier.fillMaxWidth()) + .wrapContentHeight() + .onGloballyPositioned { coords -> + cardSize = Size(coords.size.width.toFloat(), coords.size.height.toFloat()) + val position = coords.positionInWindow() + viewModel.setFloatingCardBounds( + FloatingCardBounds( + left = position.x, + top = position.y, + width = coords.size.width.toFloat(), + height = coords.size.height.toFloat(), + ), + ) + }.graphicsLayer { + translationX = clampOffsetX(liveOffsetX) + translationY = clampOffsetY(liveOffsetY) + this.scaleX = liveScaleX + this.scaleY = liveScaleY + }.shadow(elevation = 8.dp, shape = RoundedCornerShape(CARD_CORNER), clip = false) + .clip(RoundedCornerShape(CARD_CORNER)) + .background(cardColor), + ) { + Column { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(HANDLE_STRIP_HEIGHT) + .pointerInput(Unit) { + detectDragGestures( + onDragEnd = { persist(liveOffsetY > dockThresholdPx) }, + onDragCancel = { persist(false) }, + ) { change, dragAmount -> + change.consume() + liveOffsetX = clampOffsetX(liveOffsetX + dragAmount.x) + liveOffsetY = clampOffsetY(liveOffsetY + dragAmount.y) + } + }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .width(36.dp) + .height(4.dp) + .background(handleColor, RoundedCornerShape(2.dp)), + ) + } + + content() + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(HANDLE_STRIP_HEIGHT), + ) + } + + listOf( + Alignment.TopStart to (-1f to -1f), + Alignment.TopEnd to (1f to -1f), + Alignment.BottomStart to (-1f to 1f), + Alignment.BottomEnd to (1f to 1f), + ).forEach { (alignment, factors) -> + ResizeHandle( + modifier = Modifier.align(alignment), + color = cornerColor, + dragFactorX = factors.first, + dragFactorY = factors.second, + cardSize = { cardSize }, + currentScale = { liveScaleX to liveScaleY }, + onScale = { sx, sy -> + liveScaleX = sx + liveScaleY = sy + }, + onEnd = { persist(false) }, + ) + } + } + } +} + +@Composable +private fun ResizeHandle( + color: Color, + dragFactorX: Float, + dragFactorY: Float, + cardSize: () -> Size, + currentScale: () -> Pair, + onScale: (Float, Float) -> Unit, + onEnd: () -> Unit, + modifier: Modifier = Modifier, +) { + val gesture = remember { ResizeGestureState() } + + Box( + modifier = + modifier + .size(RESIZE_TOUCH_SIZE) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { + val (sx, sy) = currentScale() + gesture.initialScaleX = sx + gesture.initialScaleY = sy + gesture.accumulatedDx = 0f + gesture.accumulatedDy = 0f + }, + onDragEnd = { onEnd() }, + onDragCancel = { onEnd() }, + ) { change, dragAmount -> + change.consume() + val size = cardSize() + if (size.width > 0f && size.height > 0f) { + gesture.accumulatedDx += dragAmount.x + gesture.accumulatedDy += dragAmount.y + val targetScaleX = + (gesture.initialScaleX + dragFactorX * gesture.accumulatedDx / size.width) + .coerceIn(MIN_SCALE, MAX_SCALE) + val targetScaleY = + (gesture.initialScaleY + dragFactorY * gesture.accumulatedDy / size.height) + .coerceIn(MIN_SCALE, MAX_SCALE) + onScale(targetScaleX, targetScaleY) + } + } + }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(RESIZE_DOT_SIZE) + .background(color, CircleShape), + ) + } +} + +private class ResizeGestureState { + var initialScaleX = 1f + var initialScaleY = 1f + var accumulatedDx = 0f + var accumulatedDy = 0f +} diff --git a/app/src/keyboards/java/be/scri/ui/compose/IMSLifecycleOwner.kt b/app/src/keyboards/java/be/scri/ui/compose/IMSLifecycleOwner.kt new file mode 100644 index 000000000..7f9e7f21b --- /dev/null +++ b/app/src/keyboards/java/be/scri/ui/compose/IMSLifecycleOwner.kt @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.compose + +import android.os.Bundle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner + +/** + * A custom LifecycleOwner for InputMethodService to host Compose views. + * InputMethodService does not provide these by default, but Compose needs them. + */ +class IMSLifecycleOwner : + LifecycleOwner, + ViewModelStoreOwner, + SavedStateRegistryOwner { + private val lifecycleRegistry = LifecycleRegistry(this) + private val savedStateRegistryController = SavedStateRegistryController.create(this) + private val store = ViewModelStore() + + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + override val savedStateRegistry: SavedStateRegistry + get() = savedStateRegistryController.savedStateRegistry + + override val viewModelStore: ViewModelStore + get() = store + + fun onCreate() { + savedStateRegistryController.performRestore(Bundle()) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + } + + fun onResume() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + fun onPause() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + } + + fun onDestroy() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + store.clear() + } +} diff --git a/app/src/keyboards/java/be/scri/ui/compose/InfoWikiBanner.kt b/app/src/keyboards/java/be/scri/ui/compose/InfoWikiBanner.kt new file mode 100644 index 000000000..3559001bd --- /dev/null +++ b/app/src/keyboards/java/be/scri/ui/compose/InfoWikiBanner.kt @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun InfoWikiBanner( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val invalidTexts by viewModel.invalidInfoTexts.collectAsState() + + var currentPage by remember(invalidTexts) { mutableIntStateOf(0) } + + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val bgColor = if (isDarkMode) Color(0xFF282828) else Color(0xFFEBEBEB) + val textColor = if (isDarkMode) Color.White else Color.Black + val arrowColor = if (isDarkMode) Color.White else Color.Black + val closeBtnBg = if (isDarkMode) Color(0xFF404040) else Color.LightGray + val dotActive = if (isDarkMode) Color.White else Color.Black + val dotInactive = if (isDarkMode) Color.DarkGray else Color.Gray + + Box( + modifier = + modifier + .fillMaxWidth() + .height(250.dp) // Standard keyboard height approx + .background(bgColor) + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // Left Button + Box( + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .clickable(enabled = currentPage > 0) { + if (currentPage > 0) currentPage-- + }, + contentAlignment = Alignment.Center, + ) { + if (currentPage > 0) { + Text( + text = "❮", + color = arrowColor, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + } + } + + // Text Content + Box( + modifier = + Modifier + .weight(1f) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + if (invalidTexts.isNotEmpty()) { + Text( + text = invalidTexts[currentPage], + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + } else { + Text( + text = "No information available.", + color = Color.Gray, + fontSize = 16.sp, + textAlign = TextAlign.Center, + ) + } + } + + // Right Button + Box( + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .clickable(enabled = currentPage < invalidTexts.size - 1) { + if (currentPage < invalidTexts.size - 1) currentPage++ + }, + contentAlignment = Alignment.Center, + ) { + if (currentPage < invalidTexts.size - 1) { + Text( + text = "❯", + color = arrowColor, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + + // Dot Indicators + if (invalidTexts.size > 1) { + Row( + modifier = Modifier.padding(top = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + for (i in invalidTexts.indices) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (i == currentPage) dotActive else dotInactive), + ) + } + } + } + + // Return to keyboard button (simulating 'ivInfo' click to close) + Box( + modifier = + Modifier + .padding(top = 16.dp) + .background(closeBtnBg, CircleShape) + .clickable { viewModel.setInvalidInfoVisible(false) } + .padding(horizontal = 24.dp, vertical = 8.dp), + ) { + Text(text = "Close Info", color = textColor, fontWeight = FontWeight.Bold) + } + } + } +} diff --git a/app/src/keyboards/java/be/scri/ui/compose/KeyboardActionListener.kt b/app/src/keyboards/java/be/scri/ui/compose/KeyboardActionListener.kt new file mode 100644 index 000000000..2dc63bf47 --- /dev/null +++ b/app/src/keyboards/java/be/scri/ui/compose/KeyboardActionListener.kt @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.compose + +import be.scri.helpers.clipboard.ClipboardItem + +interface KeyboardActionListener { + fun onPress(primaryCode: Int) + + fun onKey(code: Int) + + fun onActionUp() + + fun moveCursorLeft() + + fun moveCursorRight() + + fun onText(text: String) + + fun hasTextBeforeCursor(): Boolean + + fun commitPeriodAfterSpace() + + fun setDeleteRepeating(isRepeating: Boolean) {} + + fun onScribeKeyOptionsClicked() + + fun onScribeKeyToolbarClicked() + + fun onTranslateClicked() + + fun onConjugateClicked() + + fun onPluralClicked() + + fun onCloseClicked() + + fun onSuggestionClicked(suggestion: String) + + fun onAutocompleteSuggestionClicked(suggestion: String) {} + + fun onClipboardItemClicked(item: ClipboardItem) {} + + fun onClipboardItemDelete(item: ClipboardItem) {} + + fun onClipboardItemPinToggle(item: ClipboardItem) {} + + fun onClipboardClearAll() {} + + fun onClipboardPanelClose() {} + + fun onEmojiSelected(emoji: String) {} + + fun onDownloadDataBannerClicked() {} + + fun onFloatingGestureEnded( + offsetX: Float, + offsetY: Float, + scaleX: Float, + scaleY: Float, + dockToBottom: Boolean, + ) {} +} diff --git a/app/src/keyboards/java/be/scri/ui/compose/KeyboardViewModel.kt b/app/src/keyboards/java/be/scri/ui/compose/KeyboardViewModel.kt new file mode 100644 index 000000000..ed770667b --- /dev/null +++ b/app/src/keyboards/java/be/scri/ui/compose/KeyboardViewModel.kt @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.compose + +import be.scri.helpers.KeyboardBase +import be.scri.helpers.clipboard.ClipboardItem +import be.scri.models.ScribeState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class KeyboardViewModel { + private val _currentState = MutableStateFlow(ScribeState.IDLE) + val currentState: StateFlow = _currentState.asStateFlow() + + private val _language = MutableStateFlow("English") + val language: StateFlow = _language.asStateFlow() + + private val _keyboard = MutableStateFlow(null) + val keyboard: StateFlow = _keyboard.asStateFlow() + + private val _isNumericKeyboardActive = MutableStateFlow(false) + val isNumericKeyboardActive: StateFlow = _isNumericKeyboardActive.asStateFlow() + + private val _hasLanguageData = MutableStateFlow(true) + val hasLanguageData: StateFlow = _hasLanguageData.asStateFlow() + + private val _shiftState = MutableStateFlow(0) + val shiftState: StateFlow = _shiftState.asStateFlow() + + private val _emojiSuggestions = MutableStateFlow>(emptyList()) + val emojiSuggestions: StateFlow> = _emojiSuggestions.asStateFlow() + + private val _commandBarText = MutableStateFlow("") + val commandBarText: StateFlow = _commandBarText.asStateFlow() + + private val _commandBarHint = MutableStateFlow("") + val commandBarHint: StateFlow = _commandBarHint.asStateFlow() + + private val _commandBarHintColor = MutableStateFlow(null) + val commandBarHintColor: StateFlow = _commandBarHintColor.asStateFlow() + + private val _promptText = MutableStateFlow("") + val promptText: StateFlow = _promptText.asStateFlow() + + private val _hasData = MutableStateFlow(true) + val hasData: StateFlow = _hasData.asStateFlow() + + private val _conjugateOutput = MutableStateFlow>>?>(null) + val conjugateOutput: StateFlow>>?> = _conjugateOutput.asStateFlow() + + private val _selectedConjugationSubCategory = MutableStateFlow(null) + val selectedConjugationSubCategory: StateFlow = _selectedConjugationSubCategory.asStateFlow() + + private val _currentVerbForConjugation = MutableStateFlow(null) + val currentVerbForConjugation: StateFlow = _currentVerbForConjugation.asStateFlow() + + private val _invalidCommandSource = MutableStateFlow(ScribeState.IDLE) + val invalidCommandSource: StateFlow = _invalidCommandSource.asStateFlow() + + private val _isInvalidInfoVisible = MutableStateFlow(false) + val isInvalidInfoVisible: StateFlow = _isInvalidInfoVisible.asStateFlow() + + private val _invalidInfoTexts = MutableStateFlow>(emptyList()) + val invalidInfoTexts: StateFlow> = _invalidInfoTexts.asStateFlow() + + private val _invalidMsg = MutableStateFlow("") + val invalidMsg: StateFlow = _invalidMsg.asStateFlow() + + private val _suggestion1 = MutableStateFlow(null) + val suggestion1: StateFlow = _suggestion1.asStateFlow() + + private val _suggestion2 = MutableStateFlow(null) + val suggestion2: StateFlow = _suggestion2.asStateFlow() + + private val _suggestion3 = MutableStateFlow(null) + val suggestion3: StateFlow = _suggestion3.asStateFlow() + + private val _highlightedSuggestion = MutableStateFlow(null) + val highlightedSuggestion: StateFlow = _highlightedSuggestion.asStateFlow() + + private val _isEmojiColonMode = MutableStateFlow(false) + val isEmojiColonMode: StateFlow = _isEmojiColonMode.asStateFlow() + + private val _isAutocompleteActive = MutableStateFlow(false) + val isAutocompleteActive: StateFlow = _isAutocompleteActive.asStateFlow() + + private val _genderSuggestionLeft = MutableStateFlow(null) + val genderSuggestionLeft: StateFlow = _genderSuggestionLeft.asStateFlow() + + private val _genderSuggestionRight = MutableStateFlow(null) + val genderSuggestionRight: StateFlow = _genderSuggestionRight.asStateFlow() + + private val _genderColorLeft = MutableStateFlow(null) + val genderColorLeft: StateFlow = _genderColorLeft.asStateFlow() + + private val _genderColorRight = MutableStateFlow(null) + val genderColorRight: StateFlow = _genderColorRight.asStateFlow() + + private val _currencySymbol = MutableStateFlow("$") + val currencySymbol: StateFlow = _currencySymbol.asStateFlow() + + private val _bottomInsetPx = MutableStateFlow(0) + val bottomInsetPx: StateFlow = _bottomInsetPx.asStateFlow() + + private val _isClipboardPanelVisible = MutableStateFlow(false) + val isClipboardPanelVisible: StateFlow = _isClipboardPanelVisible.asStateFlow() + + private val _isEmojiKeyboardVisible = MutableStateFlow(false) + val isEmojiKeyboardVisible: StateFlow = _isEmojiKeyboardVisible.asStateFlow() + + fun setEmojiKeyboardVisible(visible: Boolean) { + _isEmojiKeyboardVisible.value = visible + } + + private val _isFloatingMode = MutableStateFlow(false) + val isFloatingMode: StateFlow = _isFloatingMode.asStateFlow() + + private val _floatingOffsetX = MutableStateFlow(0f) + val floatingOffsetX: StateFlow = _floatingOffsetX.asStateFlow() + + private val _floatingOffsetY = MutableStateFlow(0f) + val floatingOffsetY: StateFlow = _floatingOffsetY.asStateFlow() + + private val _floatingScaleX = MutableStateFlow(1f) + val floatingScaleX: StateFlow = _floatingScaleX.asStateFlow() + + private val _floatingScaleY = MutableStateFlow(1f) + val floatingScaleY: StateFlow = _floatingScaleY.asStateFlow() + + fun setFloatingMode(active: Boolean) { + _isFloatingMode.value = active + } + + fun setFloatingTransform( + offsetX: Float, + offsetY: Float, + scaleX: Float, + scaleY: Float, + ) { + _floatingOffsetX.value = offsetX + _floatingOffsetY.value = offsetY + _floatingScaleX.value = scaleX + _floatingScaleY.value = scaleY + } + + private val _floatingCardBounds = MutableStateFlow(FloatingCardBounds()) + val floatingCardBounds: StateFlow = _floatingCardBounds.asStateFlow() + + fun setFloatingCardBounds(bounds: FloatingCardBounds) { + if (_floatingCardBounds.value != bounds) { + _floatingCardBounds.value = bounds + } + } + + private val _clipboardItems = MutableStateFlow>(emptyList()) + val clipboardItems: StateFlow> = _clipboardItems.asStateFlow() + + private val _translateLabel = MutableStateFlow("Translate") + val translateLabel: StateFlow = _translateLabel.asStateFlow() + + private val _conjugateLabel = MutableStateFlow("Conjugate") + val conjugateLabel: StateFlow = _conjugateLabel.asStateFlow() + + private val _pluralLabel = MutableStateFlow("Plural") + val pluralLabel: StateFlow = _pluralLabel.asStateFlow() + + private val _clipboardSuggestion = MutableStateFlow(null) + val clipboardSuggestion: StateFlow = _clipboardSuggestion.asStateFlow() + + fun showClipboardSuggestion(text: String?) { + _clipboardSuggestion.value = text + } + + fun updateState(state: ScribeState) { + _currentState.value = state + } + + fun updateLanguage(lang: String) { + _language.value = lang + } + + fun updateKeyboard(kbd: KeyboardBase?) { + _keyboard.value = kbd + } + + fun setNumericKeyboardActive(active: Boolean) { + _isNumericKeyboardActive.value = active + } + + fun setShiftState(state: Int) { + _shiftState.value = state + } + + fun setHasLanguageData(hasData: Boolean) { + _hasLanguageData.value = hasData + } + + fun updateEmojiSuggestions(emojis: List) { + _emojiSuggestions.value = emojis + } + + fun setCommandBarText(text: String) { + _commandBarText.value = text + } + + fun setCommandBarHint(hint: String) { + _commandBarHint.value = hint + } + + fun setCommandBarHintColor(color: Int) { + _commandBarHintColor.value = color + } + + fun setPromptText(prompt: String) { + _promptText.value = prompt + } + + fun setHasData(hasData: Boolean) { + _hasData.value = hasData + } + + fun updateConjugateData( + output: Map>>?, + subCategory: String?, + verb: String?, + ) { + _conjugateOutput.value = output + _selectedConjugationSubCategory.value = subCategory + _currentVerbForConjugation.value = verb + } + + fun setInvalidCommandSource(source: ScribeState) { + _invalidCommandSource.value = source + } + + fun setInvalidInfoVisible(visible: Boolean) { + _isInvalidInfoVisible.value = visible + } + + fun setInvalidInfoTexts(texts: List) { + _invalidInfoTexts.value = texts + } + + fun setInvalidMsg(msg: String) { + _invalidMsg.value = msg + } + + fun setSuggestions( + s1: String?, + s2: String?, + s3: String?, + ) { + _suggestion1.value = s1 + _suggestion2.value = s2 + _suggestion3.value = s3 + } + + fun setAutocompleteActive(active: Boolean) { + _isAutocompleteActive.value = active + } + + fun setEmojiColonMode(enabled: Boolean) { + _isEmojiColonMode.value = enabled + } + + fun setHighlightedSuggestion(suggestion: String?) { + _highlightedSuggestion.value = suggestion + } + + fun setGenderSuggestions( + left: String?, + right: String?, + leftColor: Int? = null, + rightColor: Int? = null, + ) { + _genderSuggestionLeft.value = left + _genderSuggestionRight.value = right + _genderColorLeft.value = leftColor + _genderColorRight.value = rightColor + } + + fun setCurrencySymbol(symbol: String) { + _currencySymbol.value = symbol + } + + fun setBottomInset(px: Int) { + _bottomInsetPx.value = px + } + + fun setClipboardPanelVisible(visible: Boolean) { + _isClipboardPanelVisible.value = visible + } + + fun updateClipboardItems(items: List) { + _clipboardItems.value = items + } + + fun updateCommandLabels( + translate: String, + conjugate: String, + plural: String, + ) { + _translateLabel.value = translate + _conjugateLabel.value = conjugate + _pluralLabel.value = plural + } +} + +data class FloatingCardBounds( + val left: Float = 0f, + val top: Float = 0f, + val width: Float = 0f, + val height: Float = 0f, +) diff --git a/app/src/keyboards/java/be/scri/ui/compose/ScribeKeyboardApp.kt b/app/src/keyboards/java/be/scri/ui/compose/ScribeKeyboardApp.kt new file mode 100644 index 000000000..e8735cb6d --- /dev/null +++ b/app/src/keyboards/java/be/scri/ui/compose/ScribeKeyboardApp.kt @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp + +@Suppress("ktlint:compose:vm-forwarding-check") +@Composable +fun ScribeKeyboardApp( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val currentState by viewModel.currentState.collectAsState() + val isInvalidInfoVisible by viewModel.isInvalidInfoVisible.collectAsState() + val isClipboardPanelVisible by viewModel.isClipboardPanelVisible.collectAsState() + val isEmojiKeyboardVisible by viewModel.isEmojiKeyboardVisible.collectAsState() + val isFloatingMode by viewModel.isFloatingMode.collectAsState() + + val bottomInsetPx by viewModel.bottomInsetPx.collectAsState() + val bottomPaddingDp = with(LocalDensity.current) { bottomInsetPx.toDp() + 48.dp } + + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val keyboardBgColor = if (isDarkMode) Color(0xFF2C2C2E) else Color(0xFFD1D4DB) + + val body: @Composable () -> Unit = { + Column( + modifier = + modifier + .fillMaxWidth() + .background(keyboardBgColor) + .padding(bottom = if (isFloatingMode) 0.dp else bottomPaddingDp), + ) { + if (!isClipboardPanelVisible && !isEmojiKeyboardVisible) { + TopBarSection(viewModel = viewModel, actionListener = actionListener) + } + + if (isClipboardPanelVisible) { + ClipboardPanel(viewModel = viewModel, actionListener = actionListener) + } else if (isEmojiKeyboardVisible) { + EmojiKeyboardPanel(viewModel = viewModel, actionListener = actionListener) + } else if (isInvalidInfoVisible) { + InfoWikiBanner(viewModel = viewModel, actionListener = actionListener) + } else if (currentState == be.scri.models.ScribeState.SELECT_VERB_CONJUNCTION) { + ConjugateGrid(viewModel = viewModel, actionListener = actionListener) + } else { + ComposeKeyboardView(viewModel = viewModel, actionListener = actionListener) + } + } + } + + if (isFloatingMode) { + FloatingKeyboardChrome(viewModel = viewModel, actionListener = actionListener, content = body) + } else { + body() + } +} diff --git a/app/src/keyboards/java/be/scri/ui/compose/TopBars.kt b/app/src/keyboards/java/be/scri/ui/compose/TopBars.kt new file mode 100644 index 000000000..b73c9b673 --- /dev/null +++ b/app/src/keyboards/java/be/scri/ui/compose/TopBars.kt @@ -0,0 +1,773 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import be.scri.R +import be.scri.models.ScribeState + +private val MIN_COMMAND_FONT_SIZE = 10.sp +private val SCRIBE_BLUE = Color(0xFF54B0E6) +private val SUGGESTION_HIGHLIGHT_CORNER_RADIUS = 12.dp +private const val SUGGESTION_HIGHLIGHT_ALPHA = 0.2f +private const val EMOJI_ROW_SLOTS_PHONE = 6 +private const val EMOJI_ROW_SLOTS_TABLET = 9 +private const val TABLET_SMALLEST_WIDTH_DP = 600 +private const val COMMAND_FONT_STEP = 0.92f +private const val COMMAND_BAR_CURSOR = "|" + +@Suppress("ktlint:compose:vm-forwarding-check") +@Composable +fun TopBarSection( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val currentState by viewModel.currentState.collectAsState() + val isNumeric by viewModel.isNumericKeyboardActive.collectAsState() + val hasLanguageData by viewModel.hasLanguageData.collectAsState() + val isEmojiColonMode by viewModel.isEmojiColonMode.collectAsState() + + if (hasLanguageData && isNumeric) { + return + } + + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val bgColor = if (isDarkMode) Color(0xFF2C2C2E) else Color(0xFFD1D4DB) + + Box( + modifier = + modifier + .fillMaxWidth() + .height(48.dp) + .background(bgColor), + ) { + if (!hasLanguageData) { + EmptyStateBanner(onClick = { actionListener.onDownloadDataBannerClicked() }) + } else if (isEmojiColonMode) { + EmojiSuggestionRow(viewModel = viewModel, actionListener = actionListener) + } else { + when (currentState) { + ScribeState.IDLE -> IdleTopBar(viewModel, actionListener) + ScribeState.SELECT_COMMAND -> SelectCommandTopBar(viewModel, actionListener) + ScribeState.INVALID, ScribeState.ALREADY_PLURAL -> InvalidTopBar(viewModel, actionListener) + else -> ActiveCommandTopBar(viewModel, actionListener) + } + } + } +} + +@Composable +fun EmojiSuggestionRow( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val emojis by viewModel.emojiSuggestions.collectAsState() + val isTablet = LocalConfiguration.current.smallestScreenWidthDp >= TABLET_SMALLEST_WIDTH_DP + val slotCount = if (isTablet) EMOJI_ROW_SLOTS_TABLET else EMOJI_ROW_SLOTS_PHONE + + Row( + modifier = modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(slotCount) { index -> + val emoji = emojis.getOrNull(index) + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .clickable(enabled = emoji != null) { + emoji?.let { actionListener.onEmojiSelected(it) } + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = emoji ?: "", + fontSize = 24.sp, + maxLines = 1, + ) + } + } + } +} + +@Composable +fun EmptyStateBanner( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val textColor = if (isDarkMode) Color.White else Color.Black + val iconTint = if (isDarkMode) Color.White else Color.Black + + Box( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 6.dp) + .background(Color.Transparent) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.clouddownload), + contentDescription = null, + tint = iconTint, + modifier = Modifier.padding(end = 4.dp).size(24.dp), + ) + Text( + text = "Please download language data", + color = textColor, + fontSize = 16.sp, + ) + } + } +} + +@Composable +private fun getGenderColor( + text: String, + isDarkMode: Boolean, +): Color { + val t = text.uppercase().trim() + return when { + t == "F" -> if (isDarkMode) Color(0xFFFB5F6C) else Color(0xFF9F1722) + t == "M" -> if (isDarkMode) Color(0xFF339EEB) else Color(0xFF335C99) + t == "N" -> if (isDarkMode) Color(0xFF85C26F) else Color(0xFF3D7946) + t == "PL" || t == "P" -> if (isDarkMode) Color(0xFFFD9F5D) else Color(0xFFF85A39) + t == "C" -> if (isDarkMode) Color(0xFFAC6DEC) else Color(0xFF700589) + t.startsWith("GEN") || + t.startsWith("ACC") || + t.startsWith("DAT") || + t.startsWith("LOC") || + t.startsWith("PRE") || + t.startsWith("INS") || + t.startsWith("AKK") -> { + if (isDarkMode) Color(0xFFFD9F5D) else Color(0xFFF85A39) + } + else -> if (isDarkMode) Color(0xFFFD9F5D) else Color(0xFFF85A39) + } +} + +@Composable +fun IdleTopBar( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val s1 by viewModel.suggestion1.collectAsState() + val s2 by viewModel.suggestion2.collectAsState() + val s3 by viewModel.suggestion3.collectAsState() + val emojis by viewModel.emojiSuggestions.collectAsState() + val clipboardSuggestion by viewModel.clipboardSuggestion.collectAsState() + val genderLeft by viewModel.genderSuggestionLeft.collectAsState() + val genderRight by viewModel.genderSuggestionRight.collectAsState() + val highlighted by viewModel.highlightedSuggestion.collectAsState() + val isAutocompleteActive by viewModel.isAutocompleteActive.collectAsState() + + val isHighlighted = { candidate: String -> candidate.isNotBlank() && candidate.equals(highlighted, ignoreCase = true) } + val onWordSuggestionClicked = { word: String -> + if (isAutocompleteActive) { + actionListener.onAutocompleteSuggestionClicked(word) + } else { + actionListener.onSuggestionClicked(word) + } + } + + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val dividerColor = if (isDarkMode) Color(0xFF48484A) else Color(0xFFB8B8BC) + val scribeBtnBg = Color(0xFF54B0E6) + + Row( + modifier = modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .padding(start = 4.dp) + .width(65.dp) + .height(37.dp) + .background(scribeBtnBg, RoundedCornerShape(8.dp)) + .clickable { actionListener.onScribeKeyOptionsClicked() }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_scribe_icon_vector), + contentDescription = "Scribe", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + + VerticalDivider(modifier = Modifier.padding(horizontal = 2.dp).fillMaxHeight(0.66f), color = dividerColor) + + if (clipboardSuggestion != null) { + val clipText = clipboardSuggestion!! + val displayClipText = + if (clipText.length > 20) { + clipText.take(18) + "..." + } else { + clipText + } + + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .wrapContentSize() + .offset(x = (-16).dp) + .shadow( + elevation = 2.dp, + shape = RoundedCornerShape(16.dp), + clip = false, + ).background(Color(0xFF54B0E6), RoundedCornerShape(16.dp)) + .clickable { + actionListener.onText(clipText) + viewModel.showClipboardSuggestion(null) + }.padding(horizontal = 14.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_clipboard_vector), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + Text( + text = "Paste: \"$displayClipText\"", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } else { + val showGender = (genderLeft != null || genderRight != null) + if (emojis.isNotEmpty()) { + if (showGender) { + Row( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(vertical = 4.dp, horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + if (genderLeft != null) { + val color = getGenderColor(genderLeft!!, isDarkMode) + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .background(color, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = genderLeft!!, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + } + if (genderRight != null) { + val color = getGenderColor(genderRight!!, isDarkMode) + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .background(color, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = genderRight!!, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + } else { + SuggestionButton( + text = emojis.getOrNull(0) ?: s1 ?: "", + onClick = { + if (emojis.isNotEmpty()) { + actionListener.onSuggestionClicked(emojis[0]) + } else if (s1 != null) { + onWordSuggestionClicked(s1!!) + } + }, + modifier = Modifier.weight(1f), + isHighlighted = isHighlighted(emojis.getOrNull(0) ?: s1 ?: ""), + ) + } + + VerticalDivider(modifier = Modifier.fillMaxHeight(0.8f), color = dividerColor) + + SuggestionButton( + text = emojis.getOrNull(1) ?: s2 ?: "", + onClick = { + if (emojis.size > 1) { + actionListener.onSuggestionClicked(emojis[1]) + } else if (s2 != null) { + onWordSuggestionClicked(s2!!) + } + }, + modifier = Modifier.weight(1f), + isHighlighted = isHighlighted(emojis.getOrNull(1) ?: s2 ?: ""), + ) + + VerticalDivider(modifier = Modifier.fillMaxHeight(0.8f), color = dividerColor) + + SuggestionButton( + text = emojis.getOrNull(2) ?: s3 ?: "", + onClick = { + if (emojis.size > 2) { + actionListener.onSuggestionClicked(emojis[2]) + } else if (s3 != null) { + onWordSuggestionClicked(s3!!) + } + }, + modifier = Modifier.weight(1f), + isHighlighted = isHighlighted(emojis.getOrNull(2) ?: s3 ?: ""), + ) + } else { + if (showGender) { + Row( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(vertical = 4.dp, horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + if (genderLeft != null) { + val color = getGenderColor(genderLeft!!, isDarkMode) + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .background(color, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = genderLeft!!, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + } + if (genderRight != null) { + val color = getGenderColor(genderRight!!, isDarkMode) + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .background(color, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = genderRight!!, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + } else { + SuggestionButton( + text = s1 ?: "", + onClick = { + if (s1 != null) onWordSuggestionClicked(s1!!) + }, + modifier = Modifier.weight(1f), + isHighlighted = isHighlighted(s1 ?: ""), + ) + } + + VerticalDivider(modifier = Modifier.fillMaxHeight(0.8f), color = dividerColor) + + SuggestionButton( + text = s2 ?: "", + onClick = { + if (s2 != null) onWordSuggestionClicked(s2!!) + }, + modifier = Modifier.weight(1f), + isHighlighted = isHighlighted(s2 ?: ""), + ) + + VerticalDivider(modifier = Modifier.fillMaxHeight(0.8f), color = dividerColor) + + SuggestionButton( + text = s3 ?: "", + onClick = { + if (s3 != null) onWordSuggestionClicked(s3!!) + }, + modifier = Modifier.weight(1f), + isHighlighted = isHighlighted(s3 ?: ""), + ) + } + } + } +} + +@Composable +fun SelectCommandTopBar( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val closeBtnBg = Color(0xFF54B0E6) + + val translateLabel by viewModel.translateLabel.collectAsState() + val conjugateLabel by viewModel.conjugateLabel.collectAsState() + val pluralLabel by viewModel.pluralLabel.collectAsState() + + Row( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = + Modifier + .width(48.dp) + .fillMaxHeight() + .padding(vertical = 5.dp) + .background(closeBtnBg, RoundedCornerShape(8.dp)) + .clickable { actionListener.onCloseClicked() }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.close), + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + + CommandButton( + text = translateLabel, + isDarkMode = isDarkMode, + onClick = { actionListener.onTranslateClicked() }, + modifier = Modifier.weight(1f), + ) + + CommandButton( + text = conjugateLabel, + isDarkMode = isDarkMode, + onClick = { actionListener.onConjugateClicked() }, + modifier = Modifier.weight(1f), + ) + + CommandButton( + text = pluralLabel, + isDarkMode = isDarkMode, + onClick = { actionListener.onPluralClicked() }, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +fun ActiveCommandTopBar( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val promptText by viewModel.promptText.collectAsState() + val commandText by viewModel.commandBarText.collectAsState() + val hintText by viewModel.commandBarHint.collectAsState() + + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val closeBtnBg = Color(0xFF54B0E6) + val promptBg = if (isDarkMode) Color(0xFF48484A) else Color(0xFFB8B8BC) + val inputBg = if (isDarkMode) Color(0xFF3A3A3C) else Color.White + val textColor = if (isDarkMode) Color.White else Color.Black + val promptTextColor = if (isDarkMode) Color.White else Color.Black + val hintTextColor = if (isDarkMode) Color(0xFF8E8E93) else Color.Gray + + Row( + modifier = modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .width(56.dp) + .fillMaxHeight() + .padding(vertical = 4.dp, horizontal = 4.dp) + .background(closeBtnBg, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)) + .clickable { actionListener.onScribeKeyToolbarClicked() }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.close), + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + + Box( + modifier = + Modifier + .fillMaxHeight() + .padding(vertical = 4.dp) + .background(promptBg) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = promptText, color = promptTextColor, fontSize = 16.sp, fontWeight = FontWeight.Medium) + } + + val scrollState = rememberScrollState() + LaunchedEffect(commandText, scrollState.maxValue) { + scrollState.scrollTo(scrollState.maxValue) + } + + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(vertical = 4.dp, horizontal = 4.dp) + .background(inputBg, RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp)) + .padding(start = 8.dp) + .horizontalScroll(scrollState), + contentAlignment = Alignment.CenterStart, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (commandText.isNotEmpty()) { + Text( + text = commandText, + color = textColor, + fontSize = 16.sp, + maxLines = 1, + softWrap = false, + ) + } + + Text( + text = COMMAND_BAR_CURSOR, + color = textColor, + fontSize = 16.sp, + maxLines = 1, + softWrap = false, + ) + + if (commandText.isEmpty()) { + Text( + text = hintText ?: "", + color = hintTextColor, + fontSize = 16.sp, + maxLines = 1, + softWrap = false, + ) + } + } + } + } +} + +@Composable +fun InvalidTopBar( + viewModel: KeyboardViewModel, + actionListener: KeyboardActionListener, + modifier: Modifier = Modifier, +) { + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val closeBtnBg = Color(0xFF54B0E6) + val inputBg = if (isDarkMode) Color(0xFF3A3A3C) else Color.White + + Row( + modifier = modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .width(56.dp) + .fillMaxHeight() + .padding(vertical = 4.dp, horizontal = 4.dp) + .background(closeBtnBg, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)) + .clickable { actionListener.onCloseClicked() }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.close), + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(vertical = 4.dp, horizontal = 4.dp) + .background(inputBg, RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp)) + .padding(start = 8.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text(text = "Invalid command. Try again.", color = Color.Red, fontSize = 16.sp) + } + } +} + +@Composable +fun CommandButton( + text: String, + isDarkMode: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val bgColor = Color(0xFF54B0E6) + val textColor = if (isDarkMode) Color.White else Color.Black + + Box( + modifier = + modifier + .fillMaxHeight() + .padding(vertical = 5.dp) + .shadow( + elevation = 1.dp, + shape = RoundedCornerShape(8.dp), + clip = false, + ).background(bgColor, RoundedCornerShape(8.dp)) + .clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + var fontSize by remember(text) { mutableStateOf(16.sp) } + + Text( + text = text, + color = textColor, + fontSize = fontSize, + fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + modifier = Modifier.padding(horizontal = 2.dp), + onTextLayout = { result -> + if (result.didOverflowWidth && fontSize > MIN_COMMAND_FONT_SIZE) { + fontSize = fontSize * COMMAND_FONT_STEP + } + }, + ) + } +} + +@Composable +fun SuggestionButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isHighlighted: Boolean = false, +) { + val isDarkMode = + be.scri.ui.theme + .isKeyboardDarkMode() + val textColor = if (isDarkMode) Color.White else Color.Black + val highlightColor = SCRIBE_BLUE.copy(alpha = SUGGESTION_HIGHLIGHT_ALPHA) + + Box( + modifier = + modifier + .fillMaxHeight() + .padding(horizontal = 4.dp, vertical = 5.dp) + .background( + color = if (isHighlighted && text.isNotBlank()) highlightColor else Color.Transparent, + shape = RoundedCornerShape(SUGGESTION_HIGHLIGHT_CORNER_RADIUS), + ).clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + color = textColor, + fontSize = 18.sp, + fontWeight = if (isHighlighted && text.isNotBlank()) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/app/src/main/java/be/scri/helpers/AutoGridLayoutManager.kt b/app/src/main/java/be/scri/helpers/AutoGridLayoutManager.kt deleted file mode 100644 index 67839c838..000000000 --- a/app/src/main/java/be/scri/helpers/AutoGridLayoutManager.kt +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package be.scri.helpers - -import android.content.Context -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView - -/** - * A GridLayoutManager that automatically calculates the number of columns - * based on the available width and desired item width. - * - * @param context The application context. - * @param itemWidth The desired width of each item in pixels. - */ -class AutoGridLayoutManager( - context: Context, - private val itemWidth: Int, -) : GridLayoutManager(context, 1) { - // Recalculates the span count based on width before laying out children. - override fun onLayoutChildren( - recycler: RecyclerView.Recycler?, - state: RecyclerView.State?, - ) { - val width = width - val height = height - if (itemWidth > 0 && width > 0 && height > 0) { - val totalSpace = width - paddingRight - paddingLeft - val spanCount = maxOf(1, totalSpace / itemWidth) - setSpanCount(spanCount) - } - super.onLayoutChildren(recycler, state) - } -} diff --git a/app/src/main/java/be/scri/helpers/EmojiAdapter.kt b/app/src/main/java/be/scri/helpers/EmojiAdapter.kt deleted file mode 100644 index 5d44583b4..000000000 --- a/app/src/main/java/be/scri/helpers/EmojiAdapter.kt +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package be.scri.helpers - -import android.annotation.SuppressLint -import android.content.Context -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import be.scri.R - -/** - * Displaying emojis and category headers in the emoji palette. - * - * @param context The application context. - * @param items The list of items to display, either categories or emojis. - * @param itemClick Callback invoked when the user taps an emoji. - */ -class EmojiAdapter( - val context: Context, - var items: List, - val categoryHeaders: Map = emptyMap(), - val itemClick: (emoji: EmojiData) -> Unit, -) : RecyclerView.Adapter() { - private val layoutInflater = LayoutInflater.from(context) - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): RecyclerView.ViewHolder = - when (viewType) { - ITEM_TYPE_EMOJI -> - EmojiViewHolder( - layoutInflater.inflate(R.layout.item_emoji, parent, false), - ) - ITEM_TYPE_CATEGORY -> - EmojiCategoryViewHolder( - layoutInflater.inflate(R.layout.item_emoji_category_title, parent, false), - ) - else -> throw IllegalArgumentException("Unsupported view type: $viewType") - } - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - ) { - when (holder) { - is EmojiViewHolder -> holder.bindView(items[position] as Item.Emoji) - is EmojiCategoryViewHolder -> holder.bindView(items[position] as Item.Category) - } - } - - override fun getItemViewType(position: Int): Int = - when (items[position]) { - is Item.Emoji -> ITEM_TYPE_EMOJI - is Item.Category -> ITEM_TYPE_CATEGORY - } - - override fun getItemCount() = items.size - - /** - * Update the adapter's item list and refreshes the RecyclerView. - * - * @param emojiItems The new list of items to display. - */ - @SuppressLint("NotifyDataSetChanged") - fun updateItems(emojiItems: List) { - items = emojiItems - notifyDataSetChanged() - } - - /** - * ViewHolder for a single emoji item. - * - * @param view The enlarged item_emoji view. - */ - inner class EmojiViewHolder( - view: android.view.View, - ) : RecyclerView.ViewHolder(view) { - private val emojiValue: TextView = view.findViewById(R.id.emoji_value) - - fun bindView(emoji: Item.Emoji) { - emojiValue.text = emoji.emojiData.emoji - itemView.setOnClickListener { - itemClick.invoke(emoji.emojiData) - } - } - } - - /** - * ViewHolder for a category header. - * - * @param view The enlarged item_emoji_category_title view. - */ - inner class EmojiCategoryViewHolder( - view: android.view.View, - ) : RecyclerView.ViewHolder(view) { - private val emojiCategoryTitle: TextView = view.findViewById(R.id.emoji_category_title) - - fun bindView(category: Item.Category) { - emojiCategoryTitle.text = categoryHeaders[category.value] ?: category.value - } - } - - /** - * Sealed interface representing items in the emoji list. - */ - sealed interface Item { - // A single tappable emoji. - data class Emoji( - val emojiData: EmojiData, - ) : Item - - // A category header row. - data class Category( - val value: String, - ) : Item - } - - companion object { - private const val ITEM_TYPE_EMOJI = 0 - private const val ITEM_TYPE_CATEGORY = 1 - } -} diff --git a/app/src/main/java/be/scri/helpers/EmojiUtils.kt b/app/src/main/java/be/scri/helpers/EmojiUtils.kt index 8b333b018..95668898e 100644 --- a/app/src/main/java/be/scri/helpers/EmojiUtils.kt +++ b/app/src/main/java/be/scri/helpers/EmojiUtils.kt @@ -9,6 +9,7 @@ import java.util.Locale */ object EmojiUtils { private const val DATA_SIZE_2 = 2 + val COMMON_EMOJIS = listOf("😀", "❤️", "👍", "😂", "🎉", "✨", "🔥", "👋", "😊") /** * Checks if the end of a string is likely an emoji. @@ -48,11 +49,20 @@ object EmojiUtils { ic: InputConnection, emojiKeywords: HashMap>?, emojiMaxKeywordLength: Int, + emojiColonModeOn: Boolean = false, ) { val maxLookBack = emojiMaxKeywordLength.coerceAtLeast(1) ic.beginBatchEdit() try { val prevText = ic.getTextBeforeCursor(maxLookBack, 0)?.toString() ?: "" + if (emojiColonModeOn) { + val colonIndex = prevText.lastIndexOf(':') + if (colonIndex != -1) { + ic.deleteSurroundingText(prevText.length - colonIndex, 0) + } + ic.commitText(emoji, 1) + return + } val lastSpace = prevText.lastIndexOf(' ') when { prevText.isEmpty() || diff --git a/app/src/main/java/be/scri/helpers/KeyboardBase.kt b/app/src/main/java/be/scri/helpers/KeyboardBase.kt index 4c1ee3dfa..89c630bec 100644 --- a/app/src/main/java/be/scri/helpers/KeyboardBase.kt +++ b/app/src/main/java/be/scri/helpers/KeyboardBase.kt @@ -20,17 +20,8 @@ import org.xmlpull.v1.XmlPullParserException import java.io.IOException import kotlin.math.roundToInt -/** - * Loads an XML description of a keyboard and stores the attributes of the keys. A keyboard consists of rows of keys. - * @attr ref android.R.styleable#Keyboard_keyWidth - * @attr ref android.R.styleable#Keyboard_horizontalGap - */ @Suppress("LongMethod", "NestedBlockDepth", "CyclomaticComplexMethod") class KeyboardBase { - /** - * Interface for providing keyboard context to KeyboardBase without - * a direct dependency on GeneralKeyboardIME. - */ interface KeyboardContextProvider { val language: String val keyboardMode: Int @@ -41,39 +32,28 @@ class KeyboardBase { fun isFloatingModeActive(): Boolean } - /** Horizontal gap default for all rows */ private var mDefaultHorizontalGap = 0 - /** Default key width */ private var mDefaultWidth = 0 - /** Default key height */ private var mDefaultHeight = 0 - /** Is the keyboard in the shifted state */ var mShiftState = SHIFT_OFF - /** Total height of the keyboard, including the padding and keys */ var mHeight = 0 - /** Total width of the keyboard, including left side gaps and keys, but not any gaps on the right side. */ var mMinWidth = 0 - /** List of keys in this keyboard */ + private var mIsConjugateLayout = false + var mKeys: MutableList? = null - /** Width of the screen available to fit the keyboard */ private var mDisplayWidth = 0 - /** What icon should we show at Enter key */ var mEnterKeyType = IME_ACTION_NONE - /** Keyboard rows */ private val mRows = ArrayList() - /** - * Constants for keyboard layouts and the function to retrieve them. - */ companion object { private const val TAG_KEYBOARD = "Keyboard" private const val TAG_ROW = "Row" @@ -118,7 +98,6 @@ class KeyboardBase { const val CODE_CURRENCY = 1050 private const val MAX_KEYS_PER_MINI_ROW = 10 - // Sets for grouping key codes to reduce complexity in KeyHandler. val NAVIGATION_KEYS = setOf( KEYCODE_LEFT_ARROW, @@ -147,16 +126,6 @@ class KeyboardBase { CODE_2X1_BOTTOM, ) - /** - * Retrieves the dimension or fraction value from the attributes, adjusting the base value if necessary. - * - * @param a The TypedArray containing the attributes. - * @param index The index of the desired attribute. - * @param base The base value for the fraction calculation. - * @param defValue The default value to return if no valid dimension is found. - * - * @return The calculated dimension or fraction value. - */ fun getDimensionOrFraction( a: TypedArray, index: Int, @@ -172,22 +141,11 @@ class KeyboardBase { } } - /** - * Container for keys in the keyboard. - * All keys in a row are at the same Y-coordinate. - * Some of the key size defaults can be overridden per row from - * what the [KeyboardBase] defines. - * @attr ref android.R.styleable#Keyboard_keyWidth - * @attr ref android.R.styleable#Keyboard_horizontalGap - */ class Row { - /** Default width of a key in this row. */ var defaultWidth = 0 - /** Default height of a key in this row. */ var defaultHeight = 0 - /** Default horizontal gap between keys in this row. */ var defaultHorizontalGap = 0 var mKeys = ArrayList() @@ -213,7 +171,7 @@ class KeyboardBase { val sharedPreferences = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) val conjugateMode = sharedPreferences.getString("conjugate_mode_type", "2x1") defaultHeight = - if (conjugateMode != "none") { + if (parent.mIsConjugateLayout && conjugateMode != "none") { when (conjugateMode) { "2x2" -> res.getDimension(R.dimen.conjugate_view_key_height_2x2).toInt() "3x3" -> res.getDimension(R.dimen.conjugate_view_key_height_3x3).toInt() @@ -248,82 +206,41 @@ class KeyboardBase { } } - /** - * Class for describing the position and characteristics of a single key in the keyboard. - * - * @attr ref android.R.styleable#Keyboard_keyWidth - * @attr ref android.R.styleable#Keyboard_keyHeight - * @attr ref android.R.styleable#Keyboard_horizontalGap - * @attr ref android.R.styleable#Keyboard_Key_codes - * @attr ref android.R.styleable#Keyboard_Key_keyIcon - * @attr ref android.R.styleable#Keyboard_Key_keyLabel - * @attr ref android.R.styleable#Keyboard_Key_isRepeatable - * @attr ref android.R.styleable#Keyboard_Key_popupKeyboard - * @attr ref android.R.styleable#Keyboard_Key_popupCharacters - * @attr ref android.R.styleable#Keyboard_Key_keyEdgeFlags - */ class Key( parent: Row, ) { - /** Key code that this key generates. */ var code = 0 - /** Label to display */ var label: CharSequence = "" - /** First row of letters can also be used for inserting numbers by long pressing them, show those numbers */ var topSmallNumber: String = "" - /** Icon to display instead of a label. Icon takes precedence over a label */ var icon: Drawable? = null - /** Width of the key, not including the gap */ var width: Int - /** Height of the key, not including the gap */ var height: Int - /** The horizontal gap before this key */ var gap: Int - /** X coordinate of the key in the keyboard layout */ var x = 0 - /** Y coordinate of the key in the keyboard layout */ var y = 0 - /** The current pressed state of this key */ var pressed = false - /** Focused state, used after long pressing a key and swiping to alternative keys */ var focused = false - /** Popup characters showing after long pressing the key */ var popupCharacters: CharSequence? = null - /** - * Flags that specify the anchoring to edges of the keyboard for detecting touch events, - * that are just out of the boundary of the key. - * This is a bit mask of [KeyboardBase.EDGE_LEFT], [KeyboardBase.EDGE_RIGHT]. - */ private var edgeFlags = 0 - /** The keyboard that this key belongs to */ private val keyboard = parent.parent - /** If this key pops up a mini keyboard, this is the resource id for the XML layout for that keyboard. */ var popupResId = 0 - /** Whether this key repeats itself when held down */ var repeatable = false - /** Create a key with the given top-left coordinate and extract its attributes from the XML parser. - * @param res Resources associated with the caller's context. - * @param parent The row that this key belongs to. The row must already be attached to a [KeyboardBase]. - * @param x The x coordinate of the top-left. - * @param y The y coordinate of the top-left. - * @param parser The XML parser containing the attributes for this key. - */ constructor(res: Resources, parent: Row, x: Int, y: Int, parser: XmlResourceParser?) : this(parent) { this.x = x this.y = y @@ -379,15 +296,6 @@ class KeyboardBase { gap = parent.defaultHorizontalGap } - /** - * Detects if a point falls inside this key. - * @param x The x-coordinate of the point. - * @param y The y-coordinate of the point. - * - * @return whether or not the point falls inside the key. - * If the key is attached to an edge, it will assume that all points between the key and - * the edge are considered to be inside the key. - */ fun isInside( x: Int, y: Int, @@ -403,14 +311,6 @@ class KeyboardBase { } } - /** - * Creates a keyboard from the given xml key layout file. - * Removes rows that have a keyboard mode defined but don't match the specified mode. - * - * @param context The application or service context. - * @param xmlLayoutResId The resource file that contains the keyboard layout and keys. - * @param enterKeyType Determines what icon should we show on Enter key. - */ @JvmOverloads constructor( context: Context, @@ -424,20 +324,13 @@ class KeyboardBase { mDefaultHeight = mDefaultWidth mKeys = ArrayList() mEnterKeyType = enterKeyType + mIsConjugateLayout = + runCatching { + context.resources.getResourceEntryName(xmlLayoutResId).startsWith("conjugate_view") + }.getOrDefault(false) loadKeyboard(context, context.resources.getXml(xmlLayoutResId)) } - /** - * Creates a blank keyboard from the given resource file and - * populates it with the specified characters in left-to-right, top-to-bottom fashion, - * using the specified number of columns. If the specified number of columns is -1, - * then the keyboard will fit as many keys as possible in each row. - * - * @param context The application or service context. - * @param layoutTemplateResId The layout template file, containing no keys. - * @param characters The list of characters to display on the keyboard. One key will be created for each character. - * @param keyWidth The width of the popup key, make sure it is the same as the key itself. - */ constructor(context: Context, layoutTemplateResId: Int, characters: CharSequence, keyWidth: Int) : this(context, layoutTemplateResId, 0) { var x = 0 @@ -476,13 +369,6 @@ class KeyboardBase { mRows.add(row) } - /** - * Sets the keyboard shift state. - * - * @param shiftState The new shift state to apply. - * - * @return true if the shift state was changed, false otherwise. - */ fun setShifted(shiftState: Int): Boolean { if (mShiftState != shiftState) { mShiftState = @@ -495,31 +381,12 @@ class KeyboardBase { return false } - /** - * Creates a Row object from the XML resource parser. - * - * @param res The resources associated with the context. - * @param parser The XML resource parser. - * - * @return the created Row object. - */ private fun createRowFromXml( res: Resources, parser: XmlResourceParser?, context: Context, ): Row = Row(res, this, parser, context = context) - /** - * Creates a Key object from the XML resource parser and the specified coordinates. - * - * @param res The resources associated with the context. - * @param parent The parent Row that this key belongs to. - * @param x The x-coordinate of the key. - * @param y The y-coordinate of the key. - * @param parser the XML resource parser. - * - * @return the created Key object. - */ private fun createKeyFromXml( res: Resources, parent: Row, @@ -528,13 +395,6 @@ class KeyboardBase { parser: XmlResourceParser?, ): Key = Key(res, parent, x, y, parser) - /** - * Loads the keyboard configuration from the provided XML parser, populating the rows and keys. - * This method also handles edge cases like custom icons for the Enter key based on its type. - * - * @param context The application context. - * @param parser The XML resource parser. - */ @SuppressLint("UseCompatLoadingForDrawables") private fun loadKeyboard( context: Context, @@ -549,7 +409,6 @@ class KeyboardBase { var currentRow: Row? = null val res = context.resources - // Get the keyboard context provider if available. val provider = context as? KeyboardContextProvider val language = provider?.language val currentKeyboardMode = provider?.keyboardMode @@ -563,7 +422,6 @@ class KeyboardBase { true } - // Only hide the comma if we are on the main ABC keyboard in a search bar. val hideComma = isSearchBar && !periodAndCommaEnabled && @@ -651,8 +509,6 @@ class KeyboardBase { spaceKey?.let { it.width += widthToRedistribute - // After resizing the spacebar, we MUST realign ONLY the keys that come AFTER it in THIS ROW. - // This prevents affecting any other row. val spaceKeyIndex = rowToAdjust.mKeys.indexOf(it) if (spaceKeyIndex != -1) { for (i in (spaceKeyIndex + 1) until rowToAdjust.mKeys.size) { @@ -667,12 +523,6 @@ class KeyboardBase { mHeight = y } - /** - * Parses the keyboard attributes such as key width, height, and horizontal gap from the XML resource. - * - * @param res The resources associated with the context. - * @param parser The XML resource parser. - */ private fun parseKeyboardAttributes( res: Resources, parser: XmlResourceParser, @@ -686,9 +536,6 @@ class KeyboardBase { a.recycle() } - /** - * Holds custom IME (Input Method Editor) action constants. - */ object MyCustomActions { const val IME_ACTION_COMMAND = 0x00000008 } diff --git a/app/src/main/java/be/scri/helpers/NativeSuggestionEngine.kt b/app/src/main/java/be/scri/helpers/NativeSuggestionEngine.kt index cfb3778c0..009822fb9 100644 --- a/app/src/main/java/be/scri/helpers/NativeSuggestionEngine.kt +++ b/app/src/main/java/be/scri/helpers/NativeSuggestionEngine.kt @@ -134,8 +134,11 @@ class NativeSuggestionEngine(private val context: Context) { ) suggestions?.map { it.mWord } - ?.filter { it.isNotBlank() && it.lowercase(Locale.ROOT) != prefix.lowercase(Locale.ROOT) } - ?.take(limit) + ?.filter { + it.isNotBlank() && + it.lowercase(Locale.ROOT) != prefix.lowercase(Locale.ROOT) && + it.startsWith(prefix, ignoreCase = true) + }?.take(limit) ?: emptyList() } catch (e: Exception) { Log.e(TAG, "Error fetching native suggestions for $prefix", e) @@ -143,6 +146,23 @@ class NativeSuggestionEngine(private val context: Context) { } } + fun isValidWord( + language: String, + word: String, + ): Boolean { + val dict = getDictionary(language) ?: return false + if (word.isBlank()) return false + + return try { + dict.isValidWord(word) || + dict.isValidWord(word.lowercase(Locale.ROOT)) || + dict.isValidWord(word.replaceFirstChar { it.uppercaseChar() }) + } catch (e: Exception) { + Log.e(TAG, "Error checking dictionary validity for $word", e) + false + } + } + /** * Queries the native dictionary engine for next-word suggestions (bigram/trigram predictions) given the last typed word. */ diff --git a/app/src/main/java/be/scri/helpers/PreferencesHelper.kt b/app/src/main/java/be/scri/helpers/PreferencesHelper.kt index 7a255b925..f31c575cc 100644 --- a/app/src/main/java/be/scri/helpers/PreferencesHelper.kt +++ b/app/src/main/java/be/scri/helpers/PreferencesHelper.kt @@ -398,8 +398,8 @@ object PreferencesHelper { */ fun getIsDarkModeOrNot(context: Context): Boolean { val sharedPref = context.getSharedPreferences(SCRIBE_PREFS, MODE_PRIVATE) - val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK - val isSystemDarkMode = currentNightMode == Configuration.UI_MODE_NIGHT_YES + val systemNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + val isSystemDarkMode = systemNightMode == Configuration.UI_MODE_NIGHT_YES val lastSystemTheme = sharedPref.getBoolean("last_system_dark_mode", isSystemDarkMode) var isUserDarkMode = sharedPref.getBoolean("dark_mode", isSystemDarkMode) diff --git a/app/src/main/java/be/scri/helpers/clipboard/ClipboardAdapter.kt b/app/src/main/java/be/scri/helpers/clipboard/ClipboardAdapter.kt deleted file mode 100644 index 711c9774e..000000000 --- a/app/src/main/java/be/scri/helpers/clipboard/ClipboardAdapter.kt +++ /dev/null @@ -1,72 +0,0 @@ -package be.scri.helpers.clipboard - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.PopupMenu -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import be.scri.R - -class ClipboardAdapter( - private var items: List, - private val onItemClick: (ClipboardItem) -> Unit, - private val onItemDelete: (ClipboardItem) -> Unit, - private val onItemPinToggle: (ClipboardItem) -> Unit, -) : RecyclerView.Adapter() { - class ViewHolder( - view: View, - ) : RecyclerView.ViewHolder(view) { - val clipText: TextView = view.findViewById(R.id.clip_text) - val pinIcon: ImageView = view.findViewById(R.id.pin_icon) - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ViewHolder { - val view = - LayoutInflater - .from(parent.context) - .inflate(R.layout.clipboard_item, parent, false) - return ViewHolder(view) - } - - override fun onBindViewHolder( - holder: ViewHolder, - position: Int, - ) { - val item = items[position] - holder.clipText.text = item.text - holder.pinIcon.visibility = View.VISIBLE - - holder.itemView.setOnClickListener { - onItemClick(item) - } - - holder.itemView.setOnLongClickListener { view -> - val context = view.context - val popup = PopupMenu(context, view) - popup.menu.add(if (item.isPinned) "Unpin" else "Pin") - popup.menu.add("Delete") - - popup.setOnMenuItemClickListener { menuItem -> - when (menuItem.title) { - "Pin", "Unpin" -> onItemPinToggle(item) - "Delete" -> onItemDelete(item) - } - true - } - popup.show() - true - } - } - - override fun getItemCount(): Int = items.size - - fun updateItems(newItems: List) { - items = newItems - notifyDataSetChanged() - } -} diff --git a/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt b/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt index 5a8906d8c..70176ddee 100644 --- a/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt @@ -11,7 +11,7 @@ class AutoSuggestionDataManager( ) { fun getSuggestions(language: String): HashMap> { val db = fileManager.getLanguageDatabase(language) ?: return hashMapOf() - return processAllSuggestions(db) + return db.use { processAllSuggestions(it) } } private fun processAllSuggestions(db: SQLiteDatabase): HashMap> { diff --git a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt index 090874735..db64874dc 100644 --- a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt @@ -32,17 +32,20 @@ class ConjugateDataManager( yamlData: DataContract?, word: String, ): MutableMap>>? { + val db = fileManager.getConjugateDatabase(language) ?: return null val finalOutput: MutableMap>> = mutableMapOf() - yamlData?.conjugations?.values?.forEach { tenseGroup -> - val conjugateForms: MutableMap> = mutableMapOf() - tenseGroup.tenses.values.forEach { conjugationCategory -> - val forms = - conjugationCategory.tenseForms.values.map { form -> - getTheValueForTheConjugateWord(word.lowercase(), form.value, language) - } - conjugateForms[conjugationCategory.tenseTitle] = forms + db.use { database -> + yamlData?.conjugations?.values?.forEach { tenseGroup -> + val conjugateForms: MutableMap> = mutableMapOf() + tenseGroup.tenses.values.forEach { conjugationCategory -> + val forms = + conjugationCategory.tenseForms.values.map { form -> + getTheValueForTheConjugateWord(word.lowercase(), form.value, database) + } + conjugateForms[conjugationCategory.tenseTitle] = forms + } + finalOutput[tenseGroup.sectionTitle] = conjugateForms } - finalOutput[tenseGroup.sectionTitle] = conjugateForms } return if (finalOutput.isEmpty() || finalOutput.values.all { it.isEmpty() || it.values.all { forms -> forms.all { it.isEmpty() } } }) { null @@ -81,26 +84,23 @@ class ConjugateDataManager( * * @param word The base word (verb) to look up. * @param form The specific conjugation form identifier (e.g., "1ps", "past_participle"). - * @param language The language code to select the correct database. * * @return The conjugated word as a [String], or an empty string if not found. */ fun getTheValueForTheConjugateWord( word: String, form: String?, - language: String, + db: SQLiteDatabase, ): String { if (form.isNullOrEmpty()) return "" - return fileManager.getConjugateDatabase(language)?.use { db -> - if (!db.tableExists("verbs")) { - return "" - } + if (!db.tableExists("verbs")) { + return "" + } - val columnName = db.getInfinitiveColumnName() ?: return "" + val columnName = db.getInfinitiveColumnName() ?: return "" - getVerbCursor(db, word, columnName)?.use { cursor -> - getConjugatedValueFromCursor(cursor, form, language) - } + return getVerbCursor(db, word, columnName)?.use { cursor -> + getConjugatedValueFromCursor(cursor, form, db) } ?: "" } @@ -137,10 +137,10 @@ class ConjugateDataManager( private fun getConjugatedValueFromCursor( cursor: Cursor, form: String, - language: String, + db: SQLiteDatabase, ): String = if (form.contains("[")) { - parseComplexForm(cursor, form, language) + parseComplexForm(cursor, form, db) } else { try { cursor.getString(getColumnIndexWithFallback(cursor, form)) @@ -163,7 +163,7 @@ class ConjugateDataManager( private fun parseComplexForm( cursor: Cursor, form: String, - language: String, + db: SQLiteDatabase, ): String { val bracketRegex = Regex("""\[(.*?)]""") val match = bracketRegex.find(form) ?: return "" @@ -182,37 +182,43 @@ class ConjugateDataManager( val auxColumn = words.last() // Check if this column exists and get the auxiliary verb (e.g. "haben"). - val auxVerbIndex = cursor.getColumnIndex(auxColumn) - require(auxVerbIndex != -1) { "Column $auxColumn not found" } - val verbType = cursor.getString(auxVerbIndex) + val auxColumnIndex = cursor.getColumnIndex(auxColumn) + require(auxColumnIndex != -1) { "Column $auxColumn not found" } + val verbType = cursor.getString(auxColumnIndex) val targetForm = words.first() - val db = fileManager.getConjugateDatabase(language = language) var auxResult = "" val auxCursor = - db?.rawQuery( + db.rawQuery( "SELECT $targetForm FROM verbs WHERE wdLexemeId = ?", arrayOf(verbType), ) - if (auxCursor?.moveToFirst() == true) { - auxResult = auxCursor.getString(0) - } else { + val found = + auxCursor.use { cursor -> + if (cursor.moveToFirst()) { + auxResult = cursor.getString(0) + true + } else { + false + } + } + + if (!found) { // Fallback case: Maybe it stores the infinitive. - auxCursor?.close() val auxCursor2 = - db?.rawQuery( + db.rawQuery( "SELECT $targetForm FROM verbs WHERE infinitive = ?", arrayOf(verbType), ) - if (auxCursor2?.moveToFirst() == true) { - auxResult = auxCursor2.getString(0) + auxCursor2.use { cursor2 -> + if (cursor2.moveToFirst()) { + auxResult = cursor2.getString(0) + } } - auxCursor2?.close() } - auxCursor?.close() if (auxResult.isNotEmpty()) { "$auxResult $verbPart".trim() diff --git a/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt b/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt index f36c9701a..edef892eb 100644 --- a/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt @@ -68,7 +68,7 @@ class EmojiDataManager( .toMutableList() if (emojis.isNotEmpty()) { - emojiMap[word] = emojis + emojiMap[word.lowercase()] = emojis } } while (cursor.moveToNext()) } diff --git a/app/src/main/java/be/scri/helpers/data/Trie.kt b/app/src/main/java/be/scri/helpers/data/Trie.kt index 611bb252d..25f062266 100644 --- a/app/src/main/java/be/scri/helpers/data/Trie.kt +++ b/app/src/main/java/be/scri/helpers/data/Trie.kt @@ -82,8 +82,8 @@ class Trie { ) { if (results.size >= limit) return if (node.isWord) results.add(prefix) - for ((char, child) in node.children) { - collectWords(child, prefix + char, results, limit) + for (char in node.children.keys.sorted()) { + collectWords(node.children.getValue(char), prefix + char, results, limit) if (results.size >= limit) return } } diff --git a/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt b/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt index 92a50c962..339687956 100644 --- a/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt @@ -90,23 +90,26 @@ fun ConjugationSelectionScreen( if (contract != null) { val fileManager = DatabaseFileManager(context) val manager = ConjugateDataManager(fileManager) + val db = fileManager.getConjugateDatabase(languageAlias) ?: return@withContext val structuredData = mutableMapOf>>>() - contract.conjugations.values.forEach { tenseGroup -> - val categories = mutableMapOf>>() - tenseGroup.tenses.values.forEach { conjugationCategory -> - val pairs = - conjugationCategory.tenseForms.values - .map { form -> - val resolvedForm = manager.getTheValueForTheConjugateWord(verb.lowercase(), form.value, languageAlias) - form.label to resolvedForm - }.filter { it.second.isNotEmpty() } - if (pairs.isNotEmpty()) { - categories[conjugationCategory.tenseTitle] = pairs + db.use { database -> + contract.conjugations.values.forEach { tenseGroup -> + val categories = mutableMapOf>>() + tenseGroup.tenses.values.forEach { conjugationCategory -> + val pairs = + conjugationCategory.tenseForms.values + .map { form -> + val resolvedForm = manager.getTheValueForTheConjugateWord(verb.lowercase(), form.value, database) + form.label to resolvedForm + }.filter { it.second.isNotEmpty() } + if (pairs.isNotEmpty()) { + categories[conjugationCategory.tenseTitle] = pairs + } + } + if (categories.isNotEmpty()) { + structuredData[tenseGroup.sectionTitle] = categories } - } - if (categories.isNotEmpty()) { - structuredData[tenseGroup.sectionTitle] = categories } } conjugationData = structuredData diff --git a/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModel.kt index 8e22d04d6..0c93479a1 100644 --- a/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.launch /** This files handles the state and business logic for the settings screen. */ class SettingsViewModel( - context: Context, + applicationContext: Context, ) : ViewModel() { private val _languages = MutableStateFlow>(emptyList()) val languages: StateFlow> = _languages @@ -20,7 +20,7 @@ class SettingsViewModel( private val _isKeyboardInstalled = MutableStateFlow(false) val isKeyboardInstalled: StateFlow = _isKeyboardInstalled - private val sharedPrefs = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) + private val sharedPrefs = applicationContext.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) private val _vibrateOnKeypress = MutableStateFlow(sharedPrefs.getBoolean("vibrate_on_keypress", false)) val vibrateOnKeypress: StateFlow = _vibrateOnKeypress @@ -32,7 +32,7 @@ class SettingsViewModel( private val _isUserDarkMode = MutableStateFlow( be.scri.helpers.PreferencesHelper - .getIsDarkModeOrNot(context), + .getIsDarkModeOrNot(applicationContext), ) val isUserDarkMode: StateFlow = _isUserDarkMode @@ -42,7 +42,7 @@ class SettingsViewModel( val isIncreaseTextSize: StateFlow = _isIncreaseTextSize init { - viewModelScope.launch { refreshSettings(context) } + viewModelScope.launch { refreshSettings(applicationContext) } } /** diff --git a/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModelFactory.kt b/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModelFactory.kt index bb6b5f743..74443b067 100644 --- a/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModelFactory.kt +++ b/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModelFactory.kt @@ -15,7 +15,7 @@ class SettingsViewModelFactory( override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) { @Suppress("UNCHECKED_CAST") - return SettingsViewModel(context) as T + return SettingsViewModel(context.applicationContext) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/app/src/main/java/be/scri/ui/theme/ScribeTheme.kt b/app/src/main/java/be/scri/ui/theme/ScribeTheme.kt index 1e6a76af5..ec6c2cb8c 100644 --- a/app/src/main/java/be/scri/ui/theme/ScribeTheme.kt +++ b/app/src/main/java/be/scri/ui/theme/ScribeTheme.kt @@ -2,10 +2,43 @@ package be.scri.ui.theme +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import be.scri.helpers.PreferencesHelper.SCRIBE_PREFS + +private const val DARK_MODE_PREF = "dark_mode" + +@Composable +fun isKeyboardDarkMode(): Boolean { + val context = LocalContext.current + val isSystemDark = isSystemInDarkTheme() + val sharedPref = remember(context) { context.getSharedPreferences(SCRIBE_PREFS, Context.MODE_PRIVATE) } + var isDarkMode by remember(sharedPref, isSystemDark) { mutableStateOf(sharedPref.getBoolean(DARK_MODE_PREF, isSystemDark)) } + + DisposableEffect(sharedPref, isSystemDark) { + val listener = + SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> + if (key == DARK_MODE_PREF) { + isDarkMode = prefs.getBoolean(DARK_MODE_PREF, isSystemDark) + } + } + sharedPref.registerOnSharedPreferenceChangeListener(listener) + onDispose { sharedPref.unregisterOnSharedPreferenceChangeListener(listener) } + } + + return isDarkMode +} private val LightColors = lightColorScheme( diff --git a/app/src/main/java/be/scri/views/KeyboardView.kt b/app/src/main/java/be/scri/views/KeyboardView.kt deleted file mode 100644 index 3bd08f38d..000000000 --- a/app/src/main/java/be/scri/views/KeyboardView.kt +++ /dev/null @@ -1,2052 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package be.scri.views - -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Paint.Align -import android.graphics.PorterDuff -import android.graphics.Rect -import android.graphics.RectF -import android.graphics.Typeface -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.graphics.drawable.LayerDrawable -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.util.AttributeSet -import android.util.Log -import android.util.TypedValue -import android.view.Gravity -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.ViewConfiguration -import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityManager -import android.view.inputmethod.EditorInfo -import android.widget.PopupWindow -import android.widget.TextView -import androidx.core.content.edit -import androidx.core.graphics.createBitmap -import androidx.core.graphics.withSave -import be.scri.R -import be.scri.databinding.KeyboardViewKeyboardBinding -import be.scri.extensions.adjustAlpha -import be.scri.extensions.applyColorFilter -import be.scri.extensions.beGoneIf -import be.scri.extensions.config -import be.scri.extensions.darkenColor -import be.scri.extensions.getContrastColor -import be.scri.extensions.getProperBackgroundColor -import be.scri.extensions.getProperKeyColor -import be.scri.extensions.getProperPrimaryColor -import be.scri.extensions.getProperTextColor -import be.scri.extensions.getStrokeColor -import be.scri.extensions.performHapticFeedback -import be.scri.extensions.performSoundFeedback -import be.scri.helpers.KeyboardBase -import be.scri.helpers.KeyboardBase.Companion.KEYCODE_CAPS_LOCK -import be.scri.helpers.KeyboardBase.Companion.KEYCODE_DELETE -import be.scri.helpers.KeyboardBase.Companion.KEYCODE_EMOJI -import be.scri.helpers.KeyboardBase.Companion.KEYCODE_ENTER -import be.scri.helpers.KeyboardBase.Companion.KEYCODE_LEFT_ARROW -import be.scri.helpers.KeyboardBase.Companion.KEYCODE_MODE_CHANGE -import be.scri.helpers.KeyboardBase.Companion.KEYCODE_RIGHT_ARROW -import be.scri.helpers.KeyboardBase.Companion.KEYCODE_SHIFT -import be.scri.helpers.KeyboardBase.Companion.KEYCODE_SPACE -import be.scri.helpers.KeyboardBase.Companion.KEYCODE_TAB -import be.scri.helpers.KeyboardBase.Companion.SHIFT_LOCKED -import be.scri.helpers.KeyboardBase.MyCustomActions -import be.scri.helpers.MAX_KEYS_PER_MINI_ROW -import be.scri.helpers.PreferencesHelper -import be.scri.helpers.SHIFT_OFF -import be.scri.helpers.SHIFT_ON_ONE_CHAR -import be.scri.helpers.SHIFT_ON_PERMANENT -import be.scri.models.ScribeState -import java.util.Arrays -import java.util.Locale - -/** - * The base keyboard view for Scribe language keyboards application. - */ -@SuppressLint("UseCompatLoadingForDrawables") -@Suppress("LargeClass", "LongMethod", "TooManyFunctions", "NestedBlockDepth", "CyclomaticComplexMethod") -class KeyboardView - @JvmOverloads - constructor( - context: Context, - attrs: AttributeSet?, - defStyleRes: Int = 0, - ) : View(context, attrs, defStyleRes) { - /** - * Listener interface for keyboard actions such as key press, text input, or movement. - */ - interface OnKeyboardActionListener { - /** - * Called when the user presses a key. This is sent before the [.onKey] is called. - * For keys that repeat, this is only called once. - * - * @param primaryCode The unicode of the key being pressed. - * If the touch is not on a valid key, the value will be zero. - */ - fun onPress(primaryCode: Int) - - /** - * Send a key press to the listener. - * - * @param code The key that was pressed - */ - fun onKey(code: Int) - - /** - * Called when the finger has been lifted after pressing a key - */ - fun onActionUp() - - /** - * Called when the user long presses Space and moves to the left - */ - fun moveCursorLeft() - - /** - * Called when the user long presses Space and moves to the right - */ - fun moveCursorRight() - - /** - * Sends a sequence of characters to the listener. - * - * @param text The string to be displayed. - */ - fun onText(text: String) - - /** - * Checks if there is text before the current cursor position. - * - * @return true if there is text before the cursor and false otherwise. - */ - fun hasTextBeforeCursor(): Boolean - - /** - * Enters a period after a space character, used in double-tap space bar scenarios. - */ - fun commitPeriodAfterSpace() - - /** - * Sets the delete repeating state. Default no-op for implementations - * that don't need delete repeat tracking. - */ - fun setDeleteRepeating(isRepeating: Boolean) {} - } - - var mKeyboard: KeyboardBase? = null - private var mCurrentKeyIndex: Int = NOT_A_KEY - - private var mLabelTextSize = 0 - private var mKeyTextSize = 0 - - private var mTextColor = 0 - private var mBackgroundColor = 0 - private var mPrimaryColor = 0 - private var mKeyColor = 0 - - private var mPreviewText: TextView? = null - private val mPreviewPopup: PopupWindow - private var mPreviewTextSizeLarge = 0 - private var mPreviewHeight = 0 - - private val mCoordinates = IntArray(2) - private val mPopupKeyboard: PopupWindow - private var mMiniKeyboardContainer: View? = null - private var mMiniKeyboard: KeyboardView? = null - private var mMiniKeyboardOnScreen = false - private var mPopupParent: View - private var mMiniKeyboardOffsetX = 0 - private var mMiniKeyboardOffsetY = 0 - private val mMiniKeyboardCache: MutableMap - private var mKeys = ArrayList() - private var mMiniKeyboardSelectedKeyIndex = -1 - - var mOnKeyboardActionListener: OnKeyboardActionListener? = null - private var mVerticalCorrection = 0 - private var mProximityThreshold = 0 - private var mPopupPreviewX = 0 - private var mPopupPreviewY = 0 - private var mLastX = 0 - private var mLastY = 0 - - private var hoverHandler: Handler? = Handler(Looper.getMainLooper()) - private var hoverRunnable: Runnable? = null - private val hoverDelay = 400L - - private val mPaint: Paint - private var mDownTime = 0L - private var mLastMoveTime = 0L - private var mLastKey = 0 - private var mLastCodeX = 0 - private var mLastCodeY = 0 - private var mCurrentKey: Int = NOT_A_KEY - private var mLastKeyTime = 0L - private var mCurrentKeyTime = 0L - private val mKeyIndices = IntArray(NUMBER_OF_KEYS) - private var mPopupX = 0 - private var mPopupY = 0 - private var mRepeatKeyIndex = NOT_A_KEY - private var mPopupLayout = 0 - private var mAbortKey = false - private var mIsLongPressingSpace = false - private var mLastSpaceMoveX = 0 - private var mPopupMaxMoveDistance = 0f - private var mTopSmallNumberSize = 0f - private var mTopSmallNumberMarginWidth = 0f - private var mTopSmallNumberMarginHeight = 0f - private val mSpaceMoveThreshold: Int - private var ignoreTouches = false - - var mKeyLabel: String = "He" - - var mKeyLabelFPS: String = "FPS" - var mKeyLabelFPP: String = "FPP" - var mKeyLabelSPS: String = "SPS" - var mKeyLabelSPP: String = "SPP" - var mKeyLabelTPS: String = "TPS" - var mKeyLabelTPP: String = "TPP" - - var mKeyLabelTL: String = "TL" - var mKeyLabelTR: String = "TR" - var mKeyLabelBL: String = "BL" - var mKeyLabelBR: String = "BR" - - var mKeyLabel1X3TOP: String = "TOP" - var mKeyLabel1X3BOTTOM: String = "BOTTOM" - var mKeyLabel1X3LEFT: String = "LEFT" - - var topSmallLabelFPS: String = "" - var topSmallLabelFPP: String = "" - var topSmallLabelSPS: String = "" - var topSmallLabelSPP: String = "" - var topSmallLabelTPS: String = "" - var topSmallLabelTPP: String = "" - - var topSmallLabelTL: String = "" - var topSmallLabelTR: String = "" - var topSmallLabelBL: String = "" - var topSmallLabelBR: String = "" - - var mKeyLabel2X1TOP: String = "LEFT" - var mKeyLabel2X1BOTTOM: String = "RIGHT" - - var mCurrencySymbol: String = "$" - - private var mEnterKeyColor: Int = 0 - - private var mSpecialKeyColor: Int? = null - - private var mKeyBackground: Drawable? = null - - private var mToolbarHolder: View? = null - - // For multi-tap. - private var mLastTapTime = 0L - - /** Whether the keyboard bitmap needs to be redrawn before it's blitted. */ - private var mDrawPending = false - - /** The dirty region in the keyboard bitmap */ - private val mDirtyRect = Rect() - - /** The keyboard bitmap for faster updates */ - private var mBuffer: Bitmap? = null - - /** Notes if the keyboard just changed, so that we could possibly reallocate the mBuffer. */ - private var mKeyboardChanged = false - - /** The canvas for the above mutable keyboard bitmap */ - private var mCanvas: Canvas? = null - - /** The accessibility manager for accessibility support */ - private val mAccessibilityManager: AccessibilityManager - - private var mHandler: Handler? = null - - private var lastSpaceBarTapTime = 0L - - private var mKeyboardBackgroundColor = 0 - - private val alpha = FULL_ALPHA - private val redDark = (DARK_COLOR_FACTOR * FULL_ALPHA).toInt() - private val greenDark = (DARK_COLOR_FACTOR * FULL_ALPHA).toInt() - private val blueDark = (DARK_COLOR_FACTOR * FULL_ALPHA).toInt() - private val darkSpecialKey = Color.argb(FULL_ALPHA, redDark, greenDark, blueDark) - - private val red = (LIGHT_COLOR_RED_FACTOR * FULL_ALPHA).toInt() - private val green = (LIGHT_COLOR_GREEN_FACTOR * FULL_ALPHA).toInt() - private val blue = (LIGHT_COLOR_BLUE_FACTOR * FULL_ALPHA).toInt() - private val lightSpecialKey = Color.argb(alpha, red, green, blue) - - /** - * Contains constants and configuration values used across KeyboardView. - */ - companion object { - private val LONGPRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout() - private val LONG_PRESSABLE_STATE_SET = intArrayOf(R.attr.state_long_pressable) - private const val NOT_A_KEY = -1 - private const val MSG_REMOVE_PREVIEW = 1 - private const val MSG_REPEAT = 2 - private const val MSG_LONGPRESS = 3 - private const val DELAY_AFTER_PREVIEW = 100 - private const val DEBOUNCE_TIME = 70 - private const val REPEAT_INTERVAL = 50 // ~20 keys per second - private const val REPEAT_START_DELAY = 400 - private const val DOUBLE_TAP_DELAY = 300L - private const val NUMBER_OF_KEYS = 12 - private const val FULL_ALPHA = 255 - private const val DARK_COLOR_FACTOR = 0.180 - private const val LIGHT_COLOR_RED_FACTOR = 0.682 - private const val LIGHT_COLOR_GREEN_FACTOR = 0.702 - private const val LIGHT_COLOR_BLUE_FACTOR = 0.745 - private const val DEFAULT_KEY_TEXT_SIZE = 18 - private const val MARGIN_ADJUSTMENT = 10 - private const val PROXIMITY_SCALING_FACTOR = 1.4f - private const val KEY_MARGIN = 8 - private const val V_KEY_MARGIN = 16 - private const val SHADOW_OFFSET = 3 - private const val ALPHA_ADJUSTMENT_FACTOR = 0.8f - private const val SHADOW_ALPHA = 100 - private const val KEY_PADDING = 5 - private const val RECT_RADIUS = 20f - private const val SHADOW_OFFSET_Y = 9f - private const val POPUP_OFFSET_MULTIPLIER = 2.5 - private const val EXTRA_DELAY = 200L - private const val DISPLAY_LEFT = 2002 - private const val DISPLAY_RIGHT = 2001 - private const val EXTRA_PADDING = 5000 - private const val KEY_HEIGHT = 100 - private var leftShiftForLabel = 0 - private const val LEFT_RIGHT_CONJUGATE_KEY_EXTRA_HEIGHT = 340 - } - - var setPreview: Boolean = true - var setVibrate: Boolean = true - - var setSound: Boolean = false - var setHoldForAltCharacters: Boolean = false - - /** - * Sets the color of the Enter key based on a specific color or theme mode. - * - * @param color The optional color to apply. - * @param isDarkMode Whether the dark mode is enabled (optional). - */ - fun setEnterKeyColor( - color: Int? = null, - isDarkMode: Boolean? = null, - ) { - if (color != null) { - mEnterKeyColor = color - invalidateAllKeys() - } else { - when (isDarkMode) { - true -> { - mEnterKeyColor = darkSpecialKey - invalidateAllKeys() - } - else -> { - mEnterKeyColor = lightSpecialKey - invalidateAllKeys() - } - } - } - } - - /** - * Sets the icon of the Enter key based on current state. - * - * @param state The current keyboard state. - * @param earlierValue Previously assigned Enter key value (optional). - * - * @return The updated Enter key value. - */ - fun setEnterKeyIcon( - state: ScribeState, - earlierValue: Int? = null, - ): Int? { - if ((state == ScribeState.IDLE || state == ScribeState.SELECT_COMMAND) && earlierValue == null) { - return mKeyboard?.mEnterKeyType - } else if (earlierValue != null && (state == ScribeState.IDLE || state == ScribeState.SELECT_COMMAND)) { - mKeyboard?.mEnterKeyType = earlierValue - } else { - mKeyboard?.mEnterKeyType = MyCustomActions.IME_ACTION_COMMAND - mEnterKeyColor = resources.getColor(R.color.theme_scribe_blue, context.theme) - } - return earlierValue - } - - /** - * Sets the label and small text label for a specific key based on its code. - * - * @param label The main label to be displayed on the key. - * @param smallTextLabel The smaller text label to be displayed (often above or below the main label). - * @param code The unique integer code identifying the key (e.g., `KeyboardBase.CODE_FPS`). - * This code determines which internal label variables are updated. - */ - fun setKeyLabel( - label: String, - smallTextLabel: String, - code: Int, - ) { - when (code) { - KeyboardBase.CODE_FPS -> { - mKeyLabelFPS = label - topSmallLabelFPS = smallTextLabel - } - KeyboardBase.CODE_FPP -> { - mKeyLabelFPP = label - topSmallLabelFPP = smallTextLabel - } - KeyboardBase.CODE_SPS -> { - mKeyLabelSPS = label - topSmallLabelSPS = smallTextLabel - } - KeyboardBase.CODE_SPP -> { - mKeyLabelSPP = label - topSmallLabelSPP = smallTextLabel - } - KeyboardBase.CODE_TPS -> { - mKeyLabelTPS = label - topSmallLabelTPS = smallTextLabel - } - KeyboardBase.CODE_TPP -> { - mKeyLabelTPP = label - topSmallLabelTPP = smallTextLabel - } - KeyboardBase.CODE_TR -> { - mKeyLabelTR = label - topSmallLabelTR = smallTextLabel - } - KeyboardBase.CODE_TL -> { - mKeyLabelTL = label - topSmallLabelTL = smallTextLabel - } - KeyboardBase.CODE_BR -> { - mKeyLabelBR = label - topSmallLabelBR = smallTextLabel - } - KeyboardBase.CODE_1X3_CENTER -> { - mKeyLabel1X3TOP = label - } - KeyboardBase.CODE_1X3_LEFT -> { - mKeyLabel1X3LEFT = label - } - KeyboardBase.CODE_1X3_RIGHT -> { - mKeyLabel1X3BOTTOM = label - } - KeyboardBase.CODE_BL -> { - mKeyLabelBL = label - topSmallLabelBL = smallTextLabel - } - KeyboardBase.CODE_2X1_BOTTOM -> { - mKeyLabel2X1BOTTOM = label - } - KeyboardBase.CODE_2X1_TOP -> { - mKeyLabel2X1TOP = label - } - KeyboardBase.CODE_CURRENCY -> { - mCurrencySymbol = label - } - } - } - - /** - * Returns the label for a key with the given code. - * - * @param code The code of the key. - * - * @return The label for the key, or null if the key code is not recognized. - */ - fun getKeyLabel(code: Int): String? = - when (code) { - KeyboardBase.CODE_FPS -> mKeyLabelFPS - KeyboardBase.CODE_FPP -> mKeyLabelFPP - KeyboardBase.CODE_SPS -> mKeyLabelSPS - KeyboardBase.CODE_SPP -> mKeyLabelSPP - KeyboardBase.CODE_TPS -> mKeyLabelTPS - KeyboardBase.CODE_TPP -> mKeyLabelTPP - KeyboardBase.CODE_TR -> mKeyLabelTR - KeyboardBase.CODE_TL -> mKeyLabelTL - KeyboardBase.CODE_BR -> mKeyLabelBR - KeyboardBase.CODE_BL -> mKeyLabelBL - KeyboardBase.CODE_2X1_BOTTOM -> mKeyLabel2X1BOTTOM - KeyboardBase.CODE_2X1_TOP -> mKeyLabel2X1TOP - KeyboardBase.CODE_1X3_CENTER -> mKeyLabel1X3TOP - KeyboardBase.CODE_1X3_LEFT -> mKeyLabel1X3LEFT - KeyboardBase.CODE_1X3_RIGHT -> mKeyLabel1X3BOTTOM - KeyboardBase.CODE_CURRENCY -> mCurrencySymbol - else -> null - } - - private var keyboardBindingInternal: KeyboardViewKeyboardBinding? = null - val keyboardBinding: KeyboardViewKeyboardBinding - get() { - if (keyboardBindingInternal == null) { - keyboardBindingInternal = KeyboardViewKeyboardBinding.inflate(LayoutInflater.from(context)) - } - return keyboardBindingInternal!! - } - - init { - val attributes = context.obtainStyledAttributes(attrs, R.styleable.KeyboardView, 0, defStyleRes) - val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - val keyTextSize = 0 - val indexCnt = attributes.indexCount - - try { - for (i in 0 until indexCnt) { - when (val attr = attributes.getIndex(i)) { - R.styleable.KeyboardView_keyTextSize -> { - mKeyTextSize = attributes.getDimensionPixelSize(attr, DEFAULT_KEY_TEXT_SIZE) - } - } - } - } finally { - attributes.recycle() - } - - mPopupLayout = R.layout.keyboard_popup_keyboard - mKeyBackground = resources.getDrawable(R.drawable.keyboard_key_selector, context.theme) - mVerticalCorrection = resources.getDimension(R.dimen.vertical_correction).toInt() - mLabelTextSize = resources.getDimension(R.dimen.label_text_size).toInt() - mPreviewHeight = resources.getDimension(R.dimen.key_height).toInt() - mSpaceMoveThreshold = resources.getDimension(R.dimen.medium_margin).toInt() - mTextColor = context.getProperTextColor() - mBackgroundColor = context.getProperBackgroundColor() - mPrimaryColor = context.getProperPrimaryColor() - mKeyColor = context.getProperKeyColor() - - mPreviewPopup = PopupWindow(context) - mPreviewText = inflater.inflate(resources.getLayout(R.layout.keyboard_key_preview), null) as TextView - mPreviewTextSizeLarge = context.resources.getDimension(R.dimen.preview_text_size).toInt() - mPreviewPopup.contentView = mPreviewText - mPreviewPopup.setBackgroundDrawable(null) - - mPreviewPopup.isTouchable = false - mPopupKeyboard = PopupWindow(context) - mPopupKeyboard.setBackgroundDrawable(null) - mPopupParent = this - mPaint = Paint() - mPaint.isAntiAlias = true - mPaint.textSize = keyTextSize.toFloat() - mPaint.textAlign = Align.CENTER - mPaint.alpha = FULL_ALPHA - mMiniKeyboardCache = HashMap() - mAccessibilityManager = (context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager) - mPopupMaxMoveDistance = resources.getDimension(R.dimen.popup_max_move_distance) - mTopSmallNumberSize = resources.getDimension(R.dimen.small_text_size) - mTopSmallNumberMarginWidth = resources.getDimension(R.dimen.top_small_number_margin_width) - mTopSmallNumberMarginHeight = resources.getDimension(R.dimen.top_small_number_margin_height) - } - - @SuppressLint("HandlerLeak") - override fun onAttachedToWindow() { - super.onAttachedToWindow() - if (mHandler == null) { - mHandler = - object : Handler() { - override fun handleMessage(msg: Message) { - when (msg.what) { - MSG_REMOVE_PREVIEW -> mPreviewText!!.visibility = INVISIBLE - MSG_REPEAT -> - if (repeatKey(false)) { - val repeat = Message.obtain(this, MSG_REPEAT) - sendMessageDelayed(repeat, REPEAT_INTERVAL.toLong()) - } - MSG_LONGPRESS -> openPopupIfRequired(msg.obj as MotionEvent) - } - } - } - } - } - - override fun onVisibilityChanged( - changedView: View, - visibility: Int, - ) { - super.onVisibilityChanged(changedView, visibility) - - if (visibility == VISIBLE) { - mTextColor = context.getProperTextColor() - mBackgroundColor = context.resources.getColor(R.color.annotateBlue) - mPrimaryColor = context.getProperPrimaryColor() - val strokeColor = context.getStrokeColor() - - val toolbarColor = - if (context.config.isUsingSystemTheme) { - resources.getColor(R.color.you_keyboard_toolbar_color, context.theme) - } else { - resources.getColor(R.color.you_keyboard_toolbar_color, context.theme) - } - - val darkerColor = - if (context.config.isUsingSystemTheme) { - resources.getColor(R.color.you_keyboard_background_color, context.theme) - } else { - mBackgroundColor - } - - val isUserDarkMode = - be.scri.helpers.PreferencesHelper - .getIsDarkModeOrNot(context) - - val miniKeyboardBackgroundColor = - resources.getColor( - if (isUserDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color, - context.theme, - ) - - if (changedView.id == R.id.mini_keyboard_view) { - val previewBackground = background as LayerDrawable - - previewBackground - .findDrawableByLayerId(R.id.button_background_shape) - .applyColorFilter(miniKeyboardBackgroundColor) - - previewBackground - .findDrawableByLayerId(R.id.button_background_stroke) - .applyColorFilter(miniKeyboardBackgroundColor) - - background = previewBackground - } else { - background.applyColorFilter(darkerColor) - } - - val wasDarkened = mBackgroundColor != mBackgroundColor.darkenColor() - mToolbarHolder?.apply { - keyboardBinding.apply { - topKeyboardDivider.beGoneIf(wasDarkened) - topKeyboardDivider.background = ColorDrawable(strokeColor) - - background = ColorDrawable(toolbarColor) - } - } - } else { - closing() - } - } - - /** - * Attaches a keyboard to this view. - * The keyboard can be switched at any time and the view will re-layout itself to accommodate the keyboard. - * - * @param keyboard the keyboard to display in this view. - */ - fun setKeyboard(keyboard: KeyboardBase) { - if (mKeyboard != null) { - showPreview(NOT_A_KEY) - } - - removeMessages() - mKeyboard = keyboard - val keys = mKeyboard!!.mKeys - mKeys = keys!!.toMutableList() as ArrayList - requestLayout() - mKeyboardChanged = true - invalidateAllKeys() - computeProximityThreshold(keyboard) - mMiniKeyboardCache.clear() - // Not really necessary to do every time, but will free up views. - // Switching to a different keyboard should abort any pending keys so that the key up - // doesn't get delivered to the old or new keyboard. - mAbortKey = true // until the next ACTION_DOWN - } - - /** Sets the top row above the keyboard containing Scribe command buttons **/ - fun setKeyboardHolder() { - mToolbarHolder = keyboardBinding.commandField - - mToolbarHolder?.let { toolbarHolder -> - keyboardBinding.let { binding -> - } - } - } - - /** - * Triggers haptic feedback if vibration is enabled in settings. - */ - fun vibrateIfNeeded() { - if (setVibrate) { - performHapticFeedback() - } - } - - fun soundIfNeeded() { - Log.d("Souncheck", "soundIfNeeded: $setSound") - if (setSound) { - performSoundFeedback() - } - } - - /** - * Sets the state of the shift key of the keyboard, if any. - * - * @param shifted Whether or not to enable the state of the shift key - * - * @return true if the shift key state changed, false if there was no change. - */ - fun setShifted(shiftState: Int) { - if (mKeyboard?.setShifted(shiftState) == true) { - invalidateAllKeys() - } - } - - /** - * Returns the state of the shift key of the keyboard, if any. - * @return true if the shift is in a pressed state, false otherwise. - */ - private fun isShifted(): Boolean = mKeyboard?.mShiftState ?: SHIFT_OFF > SHIFT_OFF - - private fun setPopupOffset( - x: Int, - y: Int, - ) { - mMiniKeyboardOffsetX = x - mMiniKeyboardOffsetY = y - if (mPreviewPopup.isShowing) { - mPreviewPopup.dismiss() - } - } - - private fun adjustCase(label: CharSequence?): CharSequence? { - if (label == null) return null - return when { - label.toString() in listOf("tab", "caps lock") -> label - - mKeyboard?.mShiftState == SHIFT_ON_ONE_CHAR || - mKeyboard?.mShiftState == SHIFT_ON_PERMANENT - -> label.toString().uppercase(Locale.getDefault()) - - else -> label - } - } - - public override fun onMeasure( - widthMeasureSpec: Int, - heightMeasureSpec: Int, - ) { - if (mKeyboard == null) { - setMeasuredDimension(0, 0) - } else { - var width = mKeyboard!!.mMinWidth - if (MeasureSpec.getSize(widthMeasureSpec) < width + MARGIN_ADJUSTMENT) { - width = MeasureSpec.getSize(widthMeasureSpec) - } - - val extraBottomPaddingPx = (resources.displayMetrics.density * 10).toInt() - - setMeasuredDimension(width, mKeyboard!!.mHeight + extraBottomPaddingPx) - } - } - - override fun onSizeChanged( - w: Int, - h: Int, - oldw: Int, - oldh: Int, - ) { - super.onSizeChanged(w, h, oldw, oldh) - mKeyboardChanged = true - invalidateAllKeys() - } - - /** - * Compute the average distance between adjacent keys (horizontally and vertically) - * and square it to get the proximity threshold. - * We use a square here and in computing the touch distance from a key's center to avoid taking a square root. - * - * @param keyboard The base class for the keyboard UI. - */ - private fun computeProximityThreshold(keyboard: KeyboardBase?) { - if (keyboard == null) { - return - } - - val keys = mKeys - val length = keys.size - var dimensionSum = 0 - for (i in 0 until length) { - val key = keys[i] - dimensionSum += Math.min(key.width, key.height) + key.gap - } - - if (dimensionSum < 0 || length == 0) { - return - } - - mProximityThreshold = (dimensionSum * PROXIMITY_SCALING_FACTOR / length).toInt() - mProximityThreshold *= mProximityThreshold // square it - } - - public override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - if (mDrawPending || mBuffer == null || mKeyboardChanged) { - onBufferDraw() - } - canvas.drawBitmap(mBuffer!!, 0f, 0f, null) - } - - @SuppressLint("UseCompatLoadingForDrawables") - private fun onBufferDraw() { - val keyMargin = KEY_MARGIN - val vKeyMargin = if (id == R.id.mini_keyboard_view) KEY_MARGIN else V_KEY_MARGIN - val shadowOffset = SHADOW_OFFSET - if (mBuffer == null || mKeyboardChanged) { - if (mBuffer?.let { buffer -> buffer.width != width || buffer.height != height } != false) { - // Make sure our bitmap is at least 1x1. - val width = 1.coerceAtLeast(width) - val height = 1.coerceAtLeast(height) - mBuffer = createBitmap(width, height) - mCanvas = Canvas(mBuffer!!) - } - invalidateAllKeys() - mKeyboardChanged = false - } - - if (mKeyboard == null) { - return - } - - mCanvas!!.withSave { - val canvas = mCanvas - canvas!!.clipRect(mDirtyRect) - val paint = mPaint - val keys = mKeys - val isUserDarkMode = - be.scri.helpers.PreferencesHelper - .getIsDarkModeOrNot(context) - val keyBackgroundColor = - if (isUserDarkMode) { - Color.DKGRAY - } else { - Color.WHITE - } - mBackgroundColor = - if (isUserDarkMode) { - Color.DKGRAY - } else { - Color.WHITE - } - mTextColor = - if (keyBackgroundColor == Color.WHITE) { - Color.BLACK - } else { - Color.WHITE - } - mSpecialKeyColor = - if (isUserDarkMode) { - R.color.special_key_dark - } else { - R.color.special_key_light - } - val pressedColorResId = - if (isUserDarkMode) { - R.color.dark_key_press_color - } else { - R.color.light_key_press_color - } - val pressedColor = resources.getColor(pressedColorResId, context.theme) - val specialKeyColorValue = resources.getColor(mSpecialKeyColor!!, context.theme) - val focusedColorResId = - if (isUserDarkMode) { - R.color.theme_scribe_blue - } else { - R.color.light_scribe_color - } - val focusedColor = resources.getColor(focusedColorResId, context.theme) - - paint.color = mTextColor - val keyBackgroundPaint = - Paint().apply { - color = keyBackgroundColor - style = Paint.Style.FILL - } - val smallLetterPaint = - Paint().apply { - set(paint) - color = mTextColor.adjustAlpha(ALPHA_ADJUSTMENT_FACTOR) - textSize = mTopSmallNumberSize - typeface = Typeface.DEFAULT - } - val shadowPaint = - Paint().apply { - color = Color.BLACK - alpha = SHADOW_ALPHA - style = Paint.Style.FILL - } - mKeyboardBackgroundColor = - resources.getColor( - if (isUserDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color, - context.theme, - ) - canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) - if (id != R.id.mini_keyboard_view) { - canvas.drawColor(mKeyboardBackgroundColor) - } - - val keyCount = keys.size - for (i in 0 until keyCount) { - val key = keys[i] - leftShiftForLabel = 0 - - // If a key has no width, it's effectively invisible. Don't draw it or its shadow. - if (key.width == 0) { - continue - } - - val code = key.code - - val padding = KEY_PADDING - val rectRadius = RECT_RADIUS - val shadowOffsetY = SHADOW_OFFSET_Y - - if ((code == DISPLAY_LEFT) || (code == DISPLAY_RIGHT)) { - val sharedPreferences = - context.getSharedPreferences( - "keyboard_preferences", - Context.MODE_PRIVATE, - ) - sharedPreferences.edit(commit = true) { - val currentValue = sharedPreferences.getInt("conjugate_index", 0) - val newValue = - when (code) { - DISPLAY_LEFT -> currentValue + 1 - DISPLAY_RIGHT -> currentValue - 1 - else -> currentValue - } - putInt("conjugate_index", newValue) - } - val density = context.resources.displayMetrics.density - key.height = (KEY_HEIGHT * density).toInt() + LEFT_RIGHT_CONJUGATE_KEY_EXTRA_HEIGHT - } - if (code == EXTRA_PADDING) { - val density = context.resources.displayMetrics.density - key.height = 0 - key.width = 0 - } - - val shadowRect = - RectF( - (key.x + keyMargin + padding).toFloat(), - (key.y + keyMargin + padding + shadowOffsetY).toFloat(), - (key.x + key.width - keyMargin - padding).toFloat(), - (key.y + key.height - vKeyMargin - padding + shadowOffsetY).toFloat(), - ) - - val keyRect = - RectF( - (key.x + keyMargin - shadowOffset + padding).toFloat(), - (key.y + keyMargin - shadowOffset + padding).toFloat(), - (key.x + key.width - keyMargin + shadowOffset - padding).toFloat(), - (key.y + key.height - vKeyMargin + shadowOffset - padding).toFloat(), - ) - if (code != EXTRA_PADDING && (mPopupParent.id != R.id.mini_keyboard_view)) { - canvas.drawRoundRect(shadowRect, rectRadius, rectRadius, shadowPaint) - } - - val backgroundColor = - when { - key.focused -> focusedColor - key.pressed -> pressedColor - code == KEYCODE_SHIFT && mKeyboard!!.mShiftState == SHIFT_LOCKED -> pressedColor - code in listOf(KEYCODE_DELETE, KEYCODE_SHIFT, KEYCODE_MODE_CHANGE) -> specialKeyColorValue - code == KEYCODE_ENTER -> mEnterKeyColor - else -> keyBackgroundColor - } - keyBackgroundPaint.color = backgroundColor - if (code != EXTRA_PADDING) { - canvas.drawRoundRect(keyRect, rectRadius, rectRadius, keyBackgroundPaint) - } - var label = adjustCase(key.label)?.toString() - // Switch the character to uppercase if shift is pressed. - when (code) { - KeyboardBase.CODE_FPS -> { - label = mKeyLabelFPS - leftShiftForLabel = 30 - key.topSmallNumber = topSmallLabelFPS - } - - KeyboardBase.CODE_FPP -> { - label = mKeyLabelFPP - leftShiftForLabel = 30 - key.topSmallNumber = topSmallLabelFPP - } - - KeyboardBase.CODE_SPS -> { - label = mKeyLabelSPS - leftShiftForLabel = 30 - key.topSmallNumber = topSmallLabelSPS - } - - KeyboardBase.CODE_SPP -> { - label = mKeyLabelSPP - leftShiftForLabel = 30 - key.topSmallNumber = topSmallLabelSPP - } - - KeyboardBase.CODE_TPS -> { - label = mKeyLabelTPS - leftShiftForLabel = 30 - key.topSmallNumber = topSmallLabelTPS - } - - KeyboardBase.CODE_TPP -> { - label = mKeyLabelTPP - leftShiftForLabel = 30 - key.topSmallNumber = topSmallLabelTPP - } - - KeyboardBase.CODE_TL -> { - label = mKeyLabelTL - leftShiftForLabel = 30 - key.topSmallNumber = topSmallLabelTL - } - - KeyboardBase.CODE_TR -> { - label = mKeyLabelTR - leftShiftForLabel = 30 - key.topSmallNumber = topSmallLabelTR - } - - KeyboardBase.CODE_BL -> { - label = mKeyLabelBL - leftShiftForLabel = 30 - key.topSmallNumber = topSmallLabelBL - } - - KeyboardBase.CODE_BR -> { - label = mKeyLabelBR - leftShiftForLabel = 30 - key.topSmallNumber = topSmallLabelBR - } - KeyboardBase.CODE_2X1_TOP -> { - label = mKeyLabel2X1TOP - leftShiftForLabel = 30 - } - KeyboardBase.CODE_2X1_BOTTOM -> { - label = mKeyLabel2X1BOTTOM - leftShiftForLabel = 30 - } - KeyboardBase.CODE_1X3_CENTER -> { - label = mKeyLabel1X3LEFT - leftShiftForLabel = 30 - } - KeyboardBase.CODE_1X3_LEFT -> { - label = mKeyLabel1X3TOP - leftShiftForLabel = 30 - } - KeyboardBase.CODE_1X3_RIGHT -> { - label = mKeyLabel1X3BOTTOM - leftShiftForLabel = 30 - } - KeyboardBase.CODE_CURRENCY -> { - label = mCurrencySymbol - leftShiftForLabel = 30 - } - } - - canvas.translate(key.x.toFloat(), key.y.toFloat()) - if (label?.isNotEmpty() == true) { - // For characters, use large font. For labels like "Done", use small font. - if (label.length > 1) { - paint.textSize = mLabelTextSize.toFloat() - paint.typeface = Typeface.DEFAULT_BOLD - } else { - paint.textSize = mKeyTextSize.toFloat() - paint.typeface = Typeface.DEFAULT - } - - // Set key text color based on state. - paint.color = - when { - key.focused -> Color.WHITE - key.pressed -> mPrimaryColor.getContrastColor() - else -> mTextColor - } - - canvas.drawText( - label, - (key.width / 2).toFloat(), - key.height / 2 + (paint.textSize - paint.descent()) / 2, - paint, - ) - - if (key.topSmallNumber.isNotEmpty()) { - canvas.drawText( - key.topSmallNumber, - key.width - mTopSmallNumberMarginWidth - leftShiftForLabel, - mTopSmallNumberMarginHeight, - smallLetterPaint, - ) - } - - // Turn off drop shadow. - paint.setShadowLayer(0f, 0f, 0f, 0) - } else if (key.icon != null && mKeyboard != null) { - if (code == KEYCODE_SHIFT) { - val drawableId = - when (mKeyboard!!.mShiftState) { - SHIFT_OFF -> R.drawable.ic_caps_outline_vector - SHIFT_ON_ONE_CHAR -> R.drawable.ic_caps_vector - SHIFT_LOCKED -> R.drawable.ic_caps_underlined_vector - else -> R.drawable.ic_caps_outline_vector - } - key.icon = resources.getDrawable(drawableId, context.theme) - } else if (code == KEYCODE_CAPS_LOCK) { - val drawableId = - when (mKeyboard!!.mShiftState) { - SHIFT_LOCKED -> R.drawable.ic_caps_lock_on - else -> R.drawable.ic_caps_lock_off - } - key.icon = resources.getDrawable(drawableId, context.theme) - key.icon!!.applyColorFilter(mTextColor) - } - - if (code == KEYCODE_LEFT_ARROW || code == KEYCODE_RIGHT_ARROW) { - val drawableId = - when (code) { - KEYCODE_LEFT_ARROW -> R.drawable.ic_left_arrow - KEYCODE_RIGHT_ARROW -> R.drawable.ic_right_arrow - else -> null - } - drawableId?.let { - key.icon = resources.getDrawable(it, context.theme) - key.icon!!.applyColorFilter(mTextColor) - } - } - - if (code == KEYCODE_ENTER) { - val drawableId = - when (mKeyboard!!.mEnterKeyType) { - EditorInfo.IME_ACTION_SEARCH -> - R.drawable.ic_search_vector - - EditorInfo.IME_ACTION_NEXT, - EditorInfo.IME_ACTION_GO, - -> - R.drawable.ic_arrow_right_vector - - EditorInfo.IME_ACTION_SEND -> - R.drawable.ic_send_vector - - MyCustomActions.IME_ACTION_COMMAND -> - R.drawable.play_button - - else -> - R.drawable.ic_enter_vector - } - key.icon = resources.getDrawable(drawableId) - key.icon!!.applyColorFilter(mTextColor) - } else { - if (code == KeyboardBase.KEYCODE_FLOAT_TOGGLE) { - val isFloating = - (context as? KeyboardBase.KeyboardContextProvider)?.isFloatingModeActive() == true || - (mPopupParent?.context as? KeyboardBase.KeyboardContextProvider)?.isFloatingModeActive() == true - val floatIconRes = - if (isFloating) { - R.drawable.ic_keyboard_dismiss - } else { - R.drawable.ic_float_keyboard - } - key.icon = resources.getDrawable(floatIconRes, context.theme) - } - val isIconOnlyKey = - code == KEYCODE_DELETE || - code == KEYCODE_SHIFT || - code == KEYCODE_TAB || - code == KeyboardBase.KEYCODE_CLIPBOARD || - code == KeyboardBase.KEYCODE_FLOAT_TOGGLE || - code == KeyboardBase.KEYCODE_EMOJI - if (isIconOnlyKey) { - key.icon!!.applyColorFilter(mTextColor) - } - } - - // Controls where icons are located on their keys. - var iconWidth = key.icon!!.intrinsicWidth - var iconHeight = key.icon!!.intrinsicHeight - val isEmojiOrClipboard = - code == KeyboardBase.KEYCODE_EMOJI || - code == KeyboardBase.KEYCODE_CLIPBOARD || - code == KeyboardBase.KEYCODE_FLOAT_TOGGLE - val scaleFactor = if (isEmojiOrClipboard) 0.5f else 0.6f - val maxIconWidth = (key.width * scaleFactor).toInt() - val maxIconHeight = (key.height * scaleFactor).toInt() - if (iconWidth > maxIconWidth || iconHeight > maxIconHeight) { - val ratio = iconWidth.toFloat() / iconHeight.toFloat() - if (ratio > 1) { - iconWidth = maxIconWidth - iconHeight = (maxIconWidth / ratio).toInt() - } else { - iconHeight = maxIconHeight - iconWidth = (maxIconHeight * ratio).toInt() - } - } - val drawableX = (key.width - iconWidth) / 2 - val drawableY = (key.height - iconHeight) / 2 - canvas.translate(drawableX.toFloat(), drawableY.toFloat()) - key.icon!!.setBounds(0, 0, iconWidth, iconHeight) - key.icon!!.draw(canvas) - canvas.translate(-drawableX.toFloat(), -drawableY.toFloat()) - - if (code == KeyboardBase.KEYCODE_EMOJI && id != R.id.mini_keyboard_view) { - val settingsIcon = resources.getDrawable(R.drawable.ic_settings_cog_vector, context.theme) - settingsIcon.applyColorFilter(mTextColor) - val density = context.resources.displayMetrics.density - val cogSize = (12 * density).toInt() - val rightPadding = keyMargin - shadowOffset + padding + (2 * density).toInt() - val topPadding = keyMargin - shadowOffset + padding + (2 * density).toInt() - val cogX = key.width - cogSize - rightPadding - val cogY = topPadding - settingsIcon.setBounds(cogX, cogY, cogX + cogSize, cogY + cogSize) - settingsIcon.draw(canvas) - } - } - canvas.translate(-key.x.toFloat(), -key.y.toFloat()) - } - - mCanvas!! - } - mDrawPending = false - mDirtyRect.setEmpty() - } - - private fun getPressedKeyIndex( - x: Int, - y: Int, - ): Int = - mKeys.indexOfFirst { - it.isInside(x, y) - } - - private fun detectAndSendKey( - index: Int, - x: Int, - y: Int, - eventTime: Long, - ) { - if (index != NOT_A_KEY && index < mKeys.size) { - val key = mKeys[index] - getPressedKeyIndex(x, y) - mOnKeyboardActionListener!!.onKey(key.code) - mLastTapTime = eventTime - } - } - - private fun showPreview(keyIndex: Int) { - if (!setPreview) { - return - } - - val oldKeyIndex = mCurrentKeyIndex - val previewPopup = mPreviewPopup - mCurrentKeyIndex = keyIndex - // Release the old key and press the new key. - val keys = mKeys - if (oldKeyIndex != mCurrentKeyIndex) { - if (oldKeyIndex != NOT_A_KEY && keys.size > oldKeyIndex) { - val oldKey = keys[oldKeyIndex] - oldKey.pressed = false - invalidateKey(oldKeyIndex) - val keyCode = oldKey.code - sendAccessibilityEventForUnicodeCharacter( - AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED, - keyCode, - ) - } - - if (mCurrentKeyIndex != NOT_A_KEY && keys.size > mCurrentKeyIndex) { - val newKey = keys[mCurrentKeyIndex] - val code = newKey.code - - newKey.pressed = true - - invalidateKey(mCurrentKeyIndex) - sendAccessibilityEventForUnicodeCharacter(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, code) - } - } - - // If key changed and preview is on. - if (oldKeyIndex != mCurrentKeyIndex) { - if (previewPopup.isShowing) { - if (keyIndex == NOT_A_KEY) { - mHandler!!.sendMessageDelayed( - mHandler!!.obtainMessage(MSG_REMOVE_PREVIEW), - DELAY_AFTER_PREVIEW.toLong(), - ) - } - } - - if (keyIndex != NOT_A_KEY) { - showKey(keyIndex) - } - } - } - - private fun showKey(keyIndex: Int) { - val previewPopup = mPreviewPopup - val keys = mKeys - if (keyIndex < 0 || keyIndex >= mKeys.size) { - return - } - - val key = keys[keyIndex] - if (key.icon != null) { - mPreviewText!!.setCompoundDrawables(null, null, null, key.icon) - } else { - if (key.label.length > 1) { - mPreviewText!!.setTextSize(TypedValue.COMPLEX_UNIT_PX, mKeyTextSize.toFloat()) - mPreviewText!!.typeface = Typeface.DEFAULT_BOLD - } else { - mPreviewText!!.setTextSize(TypedValue.COMPLEX_UNIT_PX, mPreviewTextSizeLarge.toFloat()) - mPreviewText!!.typeface = Typeface.DEFAULT - } - - mPreviewText!!.setCompoundDrawables(null, null, null, null) - try { - mPreviewText!!.text = adjustCase(key.label) - } catch (ignored: Exception) { - } - } - - val previewBackgroundColor = - if (context.config.isUsingSystemTheme) { - resources.getColor(R.color.you_keyboard_toolbar_color, context.theme) - } else { - mBackgroundColor - } - - val previewBackground = mPreviewText!!.background as LayerDrawable - previewBackground - .findDrawableByLayerId(R.id.button_background_shape) - .applyColorFilter(previewBackgroundColor) - - previewBackground - .findDrawableByLayerId(R.id.button_background_stroke) - .applyColorFilter(context.getStrokeColor()) - - mPreviewText!!.background = previewBackground - - mPreviewText!!.setTextColor(mTextColor) - mPreviewText!!.measure( - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), - ) - val popupWidth = Math.max(mPreviewText!!.measuredWidth, key.width) - val popupHeight = mPreviewHeight - val lp = mPreviewText!!.layoutParams - lp?.width = popupWidth - lp?.height = popupHeight - - mPopupPreviewX = key.x - mPopupPreviewY = key.y - popupHeight - - mHandler!!.removeMessages(MSG_REMOVE_PREVIEW) - getLocationInWindow(mCoordinates) - mCoordinates[0] += mMiniKeyboardOffsetX // offset may be zero - mCoordinates[1] += mMiniKeyboardOffsetY // offset may be zero - - // Set the preview background state. - mPreviewText!!.background.state = - if (key.popupResId != 0) { - LONG_PRESSABLE_STATE_SET - } else { - EMPTY_STATE_SET - } - - mPopupPreviewX += mCoordinates[0] - mPopupPreviewY += mCoordinates[1] - - // If the popup cannot be shown above the key, put it on the side. - getLocationOnScreen(mCoordinates) - if (mPopupPreviewY + mCoordinates[1] < 0) { - // If the key you're pressing is on the left side of the keyboard, show the popup on - // the right, offset by enough to see at least one key to the left/right. - if (key.x + key.width <= width / 2) { - mPopupPreviewX += (key.width * POPUP_OFFSET_MULTIPLIER).toInt() - } else { - mPopupPreviewX -= (key.width * POPUP_OFFSET_MULTIPLIER).toInt() - } - mPopupPreviewY += popupHeight - } - - previewPopup.dismiss() - - if (key.label.isNotEmpty() && key.code != KEYCODE_MODE_CHANGE && key.code != KEYCODE_SHIFT) { - previewPopup.width = popupWidth - previewPopup.height = popupHeight - previewPopup.showAtLocation(mPopupParent, Gravity.NO_GRAVITY, mPopupPreviewX, mPopupPreviewY) - mPreviewText!!.visibility = VISIBLE - } - } - - private fun sendAccessibilityEventForUnicodeCharacter( - eventType: Int, - code: Int, - ) { - if (mAccessibilityManager.isEnabled) { - val event = AccessibilityEvent.obtain(eventType) - onInitializeAccessibilityEvent(event) - val text: String = - when (code) { - KEYCODE_DELETE -> context.getString(R.string.i18n_app__global_delete) - KEYCODE_ENTER -> context.getString(R.string.i18n_app_keyboard_enter) - KEYCODE_MODE_CHANGE -> context.getString(R.string.i18n_app_keyboard_change_keyboard_type) - KEYCODE_SHIFT -> context.getString(R.string.i18n_app_keyboard_shift) - else -> code.toChar().toString() - } - event.text.add(text) - mAccessibilityManager.sendAccessibilityEvent(event) - } - } - - /** - * Requests a redraw of the entire keyboard. - * Calling [.invalidate] is not sufficient because the keyboard renders the keys to an off-screen buffer and - * an invalidate() only draws the cached buffer. - */ - fun invalidateAllKeys() { - mDirtyRect.union(0, 0, width, height) - mDrawPending = true - invalidate() - } - - /** - * Invalidates a key so that it will be redrawn on the next repaint. - * Use this method if only one key is changing it's content. Any changes that - * affect the position or size of the key may not be honored. - * - * @param keyIndex The index of the key in the attached [KeyboardBase]. - */ - private fun invalidateKey(keyIndex: Int) { - if (keyIndex < 0 || keyIndex >= mKeys.size) { - return - } - - val key = mKeys[keyIndex] - mDirtyRect.union( - key.x, - key.y, - key.x + key.width, - key.y + key.height, - ) - onBufferDraw() - invalidate( - key.x, - key.y, - key.x + key.width, - key.y + key.height, - ) - } - - private fun openPopupIfRequired(me: MotionEvent): Boolean { - val currentKey = if (mCurrentKey in mKeys.indices) mKeys[mCurrentKey] else null - if (currentKey?.code == KeyboardBase.KEYCODE_EMOJI) { - val result = onLongPress(currentKey, me) - if (result) { - mAbortKey = true - showPreview(NOT_A_KEY) - } - return result - } - - if (mPopupLayout == 0 || mCurrentKey !in mKeys.indices) { - return false - } - - val popupKey = mKeys[mCurrentKey] - val result = onLongPress(popupKey, me) - if (result) { - mAbortKey = true - showPreview(NOT_A_KEY) - } - - return result - } - - private fun onLongPress( - popupKey: KeyboardBase.Key, - me: MotionEvent, - ): Boolean { - if (popupKey.code == KeyboardBase.KEYCODE_EMOJI) { - popupKey.popupResId = R.xml.keys_emoji_popup - } - - val popupKeyboardId = popupKey.popupResId - if (popupKeyboardId != 0) { - mMiniKeyboardContainer = mMiniKeyboardCache[popupKey] - if (mMiniKeyboardContainer == null) { - val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - mMiniKeyboardContainer = inflater.inflate(mPopupLayout, null) - mMiniKeyboard = - mMiniKeyboardContainer!! - .findViewById(R.id.mini_keyboard_view) - as KeyboardView - - mMiniKeyboard!!.mOnKeyboardActionListener = - object : OnKeyboardActionListener { - override fun onKey(code: Int) { - mOnKeyboardActionListener!!.onKey(code) - dismissPopupKeyboard() - } - - override fun onPress(primaryCode: Int) { - mOnKeyboardActionListener!!.onPress(primaryCode) - } - - override fun onActionUp() { - mOnKeyboardActionListener!!.onActionUp() - } - - override fun moveCursorLeft() { - mOnKeyboardActionListener!!.moveCursorLeft() - } - - override fun moveCursorRight() { - mOnKeyboardActionListener!!.moveCursorRight() - } - - override fun onText(text: String) { - mOnKeyboardActionListener!!.onText(text) - } - - override fun hasTextBeforeCursor(): Boolean = - mOnKeyboardActionListener!! - .hasTextBeforeCursor() - - override fun commitPeriodAfterSpace() { - mOnKeyboardActionListener!!.commitPeriodAfterSpace() - } - } - - val keyboard = - if (popupKey.popupCharacters != null) { - KeyboardBase(context, popupKeyboardId, popupKey.popupCharacters!!, popupKey.width) - } else { - KeyboardBase(context, popupKeyboardId, 0) - } - - mMiniKeyboard!!.setKeyboard(keyboard) - mPopupParent = this - mMiniKeyboardContainer!!.measure( - MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST), - MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST), - ) - mMiniKeyboardCache[popupKey] = mMiniKeyboardContainer - } else { - mMiniKeyboard = - mMiniKeyboardContainer!! - .findViewById(R.id.mini_keyboard_view) as KeyboardView - } - - val isUserDarkMode = - be.scri.helpers.PreferencesHelper - .getIsDarkModeOrNot(context) - - val miniKeyboardBackgroundColor = - resources.getColor( - if (isUserDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color, - context.theme, - ) - - mMiniKeyboard!!.background?.let { bg -> - if (bg is LayerDrawable) { - bg - .findDrawableByLayerId(R.id.button_background_shape) - ?.applyColorFilter(miniKeyboardBackgroundColor) - bg - .findDrawableByLayerId(R.id.button_background_stroke) - ?.applyColorFilter(miniKeyboardBackgroundColor) - } - } - - getLocationInWindow(mCoordinates) - mPopupX = popupKey.x - mPopupY = popupKey.y - - var leftX = popupKey.x + (popupKey.width - mMiniKeyboardContainer!!.measuredWidth) / 2 - leftX = leftX.coerceIn(0, (width - mMiniKeyboardContainer!!.measuredWidth).coerceAtLeast(0)) - mPopupX = leftX - mPopupY -= mMiniKeyboardContainer!!.measuredHeight - val x = mPopupX + mCoordinates[0] - val y = mPopupY + mCoordinates[1] - val xOffset = Math.max(0, x) - mMiniKeyboard!!.setPopupOffset(xOffset, y) - - // Make sure we highlight the proper key right after long pressing it, - // before any ACTION_MOVE event occurs. - val miniKeyboardX = - if (xOffset + mMiniKeyboard!!.measuredWidth <= measuredWidth) { - xOffset - } else { - measuredWidth - mMiniKeyboard!!.measuredWidth - } - - val keysCnt = mMiniKeyboard!!.mKeys.size - var selectedKeyIndex = Math.floor((me.rawX - miniKeyboardX) / popupKey.width.toDouble()).toInt() - if (keysCnt > MAX_KEYS_PER_MINI_ROW) { - selectedKeyIndex += MAX_KEYS_PER_MINI_ROW - } - selectedKeyIndex = Math.max(0, Math.min(selectedKeyIndex, keysCnt - 1)) - - val isEmojiPopup = - mMiniKeyboard!!.mKeys.any { - it.code == KeyboardBase.KEYCODE_EMOJI || - it.code == KeyboardBase.KEYCODE_CLIPBOARD || - it.code == KeyboardBase.KEYCODE_FLOAT_TOGGLE - } - if (isEmojiPopup) { - // Emoji popup: start with no pre-selection; user slides to choose and lifts to confirm. - for (i in 0 until keysCnt) { - mMiniKeyboard!!.mKeys[i].focused = false - } - mMiniKeyboardSelectedKeyIndex = -1 - } else if (setHoldForAltCharacters) { - for (i in 0 until keysCnt) { - mMiniKeyboard!!.mKeys[i].focused = i == selectedKeyIndex - } - mMiniKeyboardSelectedKeyIndex = selectedKeyIndex - } else { - for (i in 0 until keysCnt) { - mMiniKeyboard!!.mKeys[i].focused = false - } - mMiniKeyboardSelectedKeyIndex = -1 - } - - mMiniKeyboard!!.invalidateAllKeys() - val miniShiftStatus = if (isShifted()) SHIFT_ON_PERMANENT else SHIFT_OFF - mMiniKeyboard!!.setShifted(miniShiftStatus) - mPopupKeyboard.contentView = mMiniKeyboardContainer - mPopupKeyboard.width = mMiniKeyboardContainer!!.measuredWidth - mPopupKeyboard.height = mMiniKeyboardContainer!!.measuredHeight - mPopupKeyboard.showAtLocation(this, Gravity.NO_GRAVITY, x, y) - mMiniKeyboardOnScreen = true - invalidateAllKeys() - return true - } - return false - } - - override fun onTouchEvent(me: MotionEvent): Boolean { - val action = me.action - - if (ignoreTouches) { - if (action == MotionEvent.ACTION_UP) { - ignoreTouches = false - - // Fix a glitch with long pressing backspace, then clicking some letter. - if (mRepeatKeyIndex != NOT_A_KEY) { - val key = mKeys[mRepeatKeyIndex] - if (key.code == KEYCODE_DELETE) { - mHandler?.removeMessages(MSG_REPEAT) - mOnKeyboardActionListener?.setDeleteRepeating(false) - mRepeatKeyIndex = NOT_A_KEY - } - } - } - return true - } - - if (mPopupKeyboard.isShowing) { - val isEmojiPopup = - mMiniKeyboard?.mKeys?.any { - it.code == KeyboardBase.KEYCODE_EMOJI || - it.code == KeyboardBase.KEYCODE_CLIPBOARD || - it.code == KeyboardBase.KEYCODE_FLOAT_TOGGLE - } == true - when (action) { - MotionEvent.ACTION_MOVE -> { - val miniKeyboard = mMiniKeyboard - val keysCnt = miniKeyboard?.mKeys?.size ?: 0 - - if (miniKeyboard != null && keysCnt > 0) { - val popupRect = Rect() - miniKeyboard.getGlobalVisibleRect(popupRect) - val widthPerKey = miniKeyboard.width / keysCnt.toFloat() - - var selectedKeyIndex = ((me.rawX - popupRect.left) / widthPerKey).toInt() - selectedKeyIndex = selectedKeyIndex.coerceIn(0, keysCnt - 1) - - if (selectedKeyIndex != mMiniKeyboardSelectedKeyIndex) { - if (setHoldForAltCharacters || isEmojiPopup) { - for (i in 0 until keysCnt) { - miniKeyboard.mKeys[i].focused = i == selectedKeyIndex - } - } else { - for (i in 0 until keysCnt) { - miniKeyboard.mKeys[i].focused = false - } - } - miniKeyboard.invalidateAllKeys() - - // Cancel pending hover if switching keys. - hoverRunnable?.let { - hoverHandler?.removeCallbacks(it) - hoverRunnable = null - } - - mMiniKeyboardSelectedKeyIndex = selectedKeyIndex - - if (!isEmojiPopup) { - // Non-emoji popup: auto-fire after hover delay. - if (setHoldForAltCharacters) { - hoverRunnable = - Runnable { - val key = miniKeyboard.mKeys[mMiniKeyboardSelectedKeyIndex] - key.focused = false - miniKeyboard.invalidateAllKeys() - - mOnKeyboardActionListener?.onKey(key.code) - mMiniKeyboardSelectedKeyIndex = -1 - hoverRunnable = null - dismissPopupKeyboard() - } - hoverHandler?.postDelayed(hoverRunnable!!, hoverDelay) - } else { - hoverRunnable = - Runnable { - val key = miniKeyboard.mKeys[mMiniKeyboardSelectedKeyIndex] - key.focused = false - miniKeyboard.invalidateAllKeys() - - mOnKeyboardActionListener?.onKey(key.code) - mMiniKeyboardSelectedKeyIndex = -1 - hoverRunnable = null - dismissPopupKeyboard() - } - hoverHandler?.postDelayed(hoverRunnable!!, 220L) - } - } - // Emoji popup: no auto-fire on hover; wait for finger lift (ACTION_UP). - } - } - } - - MotionEvent.ACTION_UP -> { - if (isEmojiPopup) { - // Fire whichever key is currently highlighted when the finger lifts. - val idx = mMiniKeyboardSelectedKeyIndex - if (idx >= 0 && idx < (mMiniKeyboard?.mKeys?.size ?: 0)) { - val key = mMiniKeyboard!!.mKeys[idx] - key.focused = false - mMiniKeyboard!!.invalidateAllKeys() - mOnKeyboardActionListener?.onKey(key.code) - } - mMiniKeyboardSelectedKeyIndex = -1 - dismissPopupKeyboard() - return true - } - } - - MotionEvent.ACTION_DOWN -> { - val popupRect = Rect() - mMiniKeyboard?.getGlobalVisibleRect(popupRect) - if (!popupRect.contains(me.rawX.toInt(), me.rawY.toInt())) { - dismissPopupKeyboard() - return onModifiedTouchEvent(me) - } - - if (!isEmojiPopup && setHoldForAltCharacters) { - if (mMiniKeyboardSelectedKeyIndex >= 0) { - val key = mMiniKeyboard!!.mKeys[mMiniKeyboardSelectedKeyIndex] - mOnKeyboardActionListener?.onKey(key.code) - mMiniKeyboardSelectedKeyIndex = -1 - } - mMiniKeyboardSelectedKeyIndex = -1 - dismissPopupKeyboard() - return true - } - } - - MotionEvent.ACTION_CANCEL -> { - mMiniKeyboardSelectedKeyIndex = -1 - dismissPopupKeyboard() - return true - } - } - return true - } - - return onModifiedTouchEvent(me) - } - - private fun onModifiedTouchEvent(me: MotionEvent): Boolean { - var touchX = me.x.toInt() - var touchY = me.y.toInt() - if (touchY >= -mVerticalCorrection) { - touchY += mVerticalCorrection - } - - var handled = false - val action = me.actionMasked - val eventTime = me.eventTime - val keyIndex = getPressedKeyIndex(touchX, touchY) - - // Ignore all motion events until a DOWN. - if (mAbortKey && action != MotionEvent.ACTION_DOWN && action != MotionEvent.ACTION_CANCEL) { - handled = true - } - - // Needs to be called after the gesture detector gets a turn, as it may have displayed the mini keyboard. - if (mMiniKeyboardOnScreen && action != MotionEvent.ACTION_CANCEL) { - return true - } - - if (!handled) { - when (action) { - MotionEvent.ACTION_POINTER_DOWN -> { - // If the user presses a key while still holding down the previous, - // type in both chars and ignore the later gestures. - // Can happen at fast typing, easier to reproduce by increasing LONGPRESS_TIMEOUT. - ignoreTouches = true - mHandler!!.removeMessages(MSG_LONGPRESS) - dismissPopupKeyboard() - detectAndSendKey(keyIndex, touchX, touchY, eventTime) - - val newPointerX = me.getX(1).toInt() - val newPointerY = me.getY(1).toInt() - val secondKeyIndex = getPressedKeyIndex(newPointerX, newPointerY) - showPreview(secondKeyIndex) - detectAndSendKey(secondKeyIndex, newPointerX, newPointerY, eventTime) - - val secondKeyCode = mKeys.getOrNull(secondKeyIndex)?.code - secondKeyCode?.let { mOnKeyboardActionListener!!.onPress(it) } - - showPreview(NOT_A_KEY) - invalidateKey(mCurrentKey) - handled = true - } - MotionEvent.ACTION_DOWN -> { - mAbortKey = false - mLastCodeX = touchX - mLastCodeY = touchY - mLastKeyTime = 0 - mCurrentKeyTime = 0 - mLastKey = NOT_A_KEY - mCurrentKey = keyIndex - mDownTime = eventTime - mLastMoveTime = eventTime - - val onPressKey = if (keyIndex != NOT_A_KEY) mKeys[keyIndex].code else 0 - mOnKeyboardActionListener!!.onPress(onPressKey) - - if (mCurrentKey >= 0 && mKeys[mCurrentKey].repeatable) { - mRepeatKeyIndex = mCurrentKey - val msg = mHandler!!.obtainMessage(MSG_REPEAT) - mHandler!!.sendMessageDelayed(msg, REPEAT_START_DELAY.toLong()) - // If the user long presses Space, move the cursor after swipine left/right. - if (mKeys[mCurrentKey].code == KEYCODE_SPACE) { - mLastSpaceMoveX = -1 - } else { - // For delete key, send the initial key press but don't set repeating flag yet. - // The repeating flag will be set when the actual repeat starts. - detectAndSendKey(mCurrentKey, mKeys[mCurrentKey].x, mKeys[mCurrentKey].y, eventTime) - } - - // Delivering the key could have caused an abort. - if (mAbortKey) { - // Reset delete repeating flag when key is aborted. - if (mRepeatKeyIndex != NOT_A_KEY && mKeys[mRepeatKeyIndex].code == KEYCODE_DELETE) { - mOnKeyboardActionListener?.setDeleteRepeating(false) - } - mRepeatKeyIndex = NOT_A_KEY - handled = true - } - } - - if (!handled && mCurrentKey != NOT_A_KEY) { - val msg = mHandler!!.obtainMessage(MSG_LONGPRESS, me) - mHandler!!.sendMessageDelayed(msg, LONGPRESS_TIMEOUT.toLong()) - } - - if (mPopupParent.id != R.id.mini_keyboard_view) { - showPreview(keyIndex) - } - } - MotionEvent.ACTION_MOVE -> { - var continueLongPress = false - if (keyIndex != NOT_A_KEY) { - if (mCurrentKey == NOT_A_KEY) { - mCurrentKey = keyIndex - mCurrentKeyTime = eventTime - mDownTime - } else { - if (keyIndex == mCurrentKey) { - mCurrentKeyTime += eventTime - mLastMoveTime - continueLongPress = true - } else if (mRepeatKeyIndex == NOT_A_KEY) { - mLastKey = mCurrentKey - mLastCodeX = mLastX - mLastCodeY = mLastY - mLastKeyTime = mCurrentKeyTime + eventTime - mLastMoveTime - mCurrentKey = keyIndex - mCurrentKeyTime = 0 - } - } - } - - if (mIsLongPressingSpace) { - if (mLastSpaceMoveX == -1) { - mLastSpaceMoveX = mLastX - } - - val diff = mLastX - mLastSpaceMoveX - if (diff < -mSpaceMoveThreshold) { - for (i in diff / mSpaceMoveThreshold until 0) { - mOnKeyboardActionListener?.moveCursorLeft() - } - mLastSpaceMoveX = mLastX - } else if (diff > mSpaceMoveThreshold) { - for (i in 0 until diff / mSpaceMoveThreshold) { - mOnKeyboardActionListener?.moveCursorRight() - } - mLastSpaceMoveX = mLastX - } - } else if (!continueLongPress) { - // Cancel old longpress. - mHandler!!.removeMessages(MSG_LONGPRESS) - // Start new longpress if key has changed. - if (keyIndex != NOT_A_KEY) { - val msg = mHandler!!.obtainMessage(MSG_LONGPRESS, me) - mHandler!!.sendMessageDelayed(msg, LONGPRESS_TIMEOUT.toLong()) - } - - if (mPopupParent.id != R.id.mini_keyboard_view) { - showPreview(mCurrentKey) - } - mLastMoveTime = eventTime - } - } - MotionEvent.ACTION_UP -> { - mLastSpaceMoveX = 0 - removeMessages() - if (keyIndex == mCurrentKey) { - mCurrentKeyTime += eventTime - mLastMoveTime - } else { - mLastKey = mCurrentKey - mLastKeyTime = mCurrentKeyTime + eventTime - mLastMoveTime - mCurrentKey = keyIndex - mCurrentKeyTime = 0 - } - - if (mCurrentKeyTime < mLastKeyTime && - mCurrentKeyTime < - DEBOUNCE_TIME && - mLastKey != NOT_A_KEY - ) { - mCurrentKey = mLastKey - touchX = mLastCodeX - touchY = mLastCodeY - } - - showPreview(NOT_A_KEY) - Arrays.fill(mKeyIndices, NOT_A_KEY) - // If we're not on a repeating key (which sends on a DOWN event). - if (mRepeatKeyIndex == NOT_A_KEY && !mMiniKeyboardOnScreen && !mAbortKey) { - detectAndSendKey(mCurrentKey, touchX, touchY, eventTime) - } - - if (mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_SPACE && !mIsLongPressingSpace) { - val currentTime = System.currentTimeMillis() - if (currentTime - lastSpaceBarTapTime < DOUBLE_TAP_DELAY + EXTRA_DELAY && - mOnKeyboardActionListener!!.hasTextBeforeCursor() - ) { - mOnKeyboardActionListener!!.commitPeriodAfterSpace() - } else { - detectAndSendKey(mCurrentKey, touchX, touchY, eventTime) - } - lastSpaceBarTapTime = currentTime - } - - invalidateKey(keyIndex) - // Reset delete repeating flag when any key is released. - if (mRepeatKeyIndex != NOT_A_KEY && mKeys[mRepeatKeyIndex].code == KEYCODE_DELETE) { - mOnKeyboardActionListener?.setDeleteRepeating(false) - } - mRepeatKeyIndex = NOT_A_KEY - mOnKeyboardActionListener!!.onActionUp() - mIsLongPressingSpace = false - } - MotionEvent.ACTION_CANCEL -> { - mIsLongPressingSpace = false - mLastSpaceMoveX = 0 - // Reset delete repeating flag when action is cancelled. - if (mRepeatKeyIndex != NOT_A_KEY && mKeys[mRepeatKeyIndex].code == KEYCODE_DELETE) { - mOnKeyboardActionListener?.setDeleteRepeating(false) - } - removeMessages() - dismissPopupKeyboard() - mAbortKey = true - showPreview(NOT_A_KEY) - invalidateKey(mCurrentKey) - } - } - } - - mLastX = touchX - mLastY = touchY - - return handled || true - } - - private fun repeatKey(initialCall: Boolean): Boolean { - val key = mKeys[mRepeatKeyIndex] - if (!initialCall && key.code == KEYCODE_SPACE) { - if (!mIsLongPressingSpace) { - vibrateIfNeeded() - } - - mIsLongPressingSpace = true - } else { - // Set delete repeating flag when repeat actually starts (not on initial press). - if (!initialCall && key.code == KEYCODE_DELETE) { - mOnKeyboardActionListener?.setDeleteRepeating(true) - } - detectAndSendKey(mCurrentKey, key.x, key.y, mLastTapTime) - } - return true - } - - private fun closing() { - if (mPreviewPopup.isShowing) { - mPreviewPopup.dismiss() - } - removeMessages() - dismissPopupKeyboard() - mBuffer = null - mCanvas = null - mMiniKeyboardCache.clear() - } - - private fun removeMessages() { - mHandler?.apply { - removeMessages(MSG_REPEAT) - removeMessages(MSG_LONGPRESS) - } - } - - public override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - closing() - } - - private fun dismissPopupKeyboard() { - if (mPopupKeyboard.isShowing) { - mPopupKeyboard.dismiss() - mMiniKeyboardOnScreen = false - invalidateAllKeys() - } - } - } diff --git a/app/src/main/res/drawable/clipboard_background.xml b/app/src/main/res/drawable/clipboard_background.xml deleted file mode 100644 index 3dcc74828..000000000 --- a/app/src/main/res/drawable/clipboard_background.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/clipboard_item_bg.xml b/app/src/main/res/drawable/clipboard_item_bg.xml deleted file mode 100644 index de0155034..000000000 --- a/app/src/main/res/drawable/clipboard_item_bg.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/clouddownload_keyboard.xml b/app/src/main/res/drawable/clouddownload_keyboard.xml deleted file mode 100644 index 732e5d4cf..000000000 --- a/app/src/main/res/drawable/clouddownload_keyboard.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/cmd_bar_background_right_rounded.xml b/app/src/main/res/drawable/cmd_bar_background_right_rounded.xml deleted file mode 100644 index 182290fb1..000000000 --- a/app/src/main/res/drawable/cmd_bar_background_right_rounded.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/cmd_bar_prompt_background.xml b/app/src/main/res/drawable/cmd_bar_prompt_background.xml deleted file mode 100644 index fedbb4f7d..000000000 --- a/app/src/main/res/drawable/cmd_bar_prompt_background.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/cmd_key_background_rounded.xml b/app/src/main/res/drawable/cmd_key_background_rounded.xml deleted file mode 100644 index 0fbe555bc..000000000 --- a/app/src/main/res/drawable/cmd_key_background_rounded.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/emoji_phone_background_rounded.xml b/app/src/main/res/drawable/emoji_phone_background_rounded.xml deleted file mode 100644 index 713badcfd..000000000 --- a/app/src/main/res/drawable/emoji_phone_background_rounded.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/emoji_tablet_background_rounded.xml b/app/src/main/res/drawable/emoji_tablet_background_rounded.xml deleted file mode 100644 index aeaefadeb..000000000 --- a/app/src/main/res/drawable/emoji_tablet_background_rounded.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/floating_keyboard_background.xml b/app/src/main/res/drawable/floating_keyboard_background.xml deleted file mode 100644 index 4b34336d7..000000000 --- a/app/src/main/res/drawable/floating_keyboard_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/gender_suggestion_button_left_background.xml b/app/src/main/res/drawable/gender_suggestion_button_left_background.xml deleted file mode 100644 index 964970f01..000000000 --- a/app/src/main/res/drawable/gender_suggestion_button_left_background.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/gender_suggestion_button_right_background.xml b/app/src/main/res/drawable/gender_suggestion_button_right_background.xml deleted file mode 100644 index b2660d67f..000000000 --- a/app/src/main/res/drawable/gender_suggestion_button_right_background.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_conjugate_command.xml b/app/src/main/res/drawable/ic_conjugate_command.xml deleted file mode 100644 index e23f6748c..000000000 --- a/app/src/main/res/drawable/ic_conjugate_command.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_custom_cursor.xml b/app/src/main/res/drawable/ic_custom_cursor.xml deleted file mode 100644 index 9f7bcd900..000000000 --- a/app/src/main/res/drawable/ic_custom_cursor.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml deleted file mode 100644 index 9f5cdf1c9..000000000 --- a/app/src/main/res/drawable/ic_drag_handle.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_plural_command.xml b/app/src/main/res/drawable/ic_plural_command.xml deleted file mode 100644 index 450ca615a..000000000 --- a/app/src/main/res/drawable/ic_plural_command.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_resize_corner.xml b/app/src/main/res/drawable/ic_resize_corner.xml deleted file mode 100644 index 868810a6d..000000000 --- a/app/src/main/res/drawable/ic_resize_corner.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_translate_command.xml b/app/src/main/res/drawable/ic_translate_command.xml deleted file mode 100644 index 5120996c6..000000000 --- a/app/src/main/res/drawable/ic_translate_command.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/keyboard_enter_background.xml b/app/src/main/res/drawable/keyboard_enter_background.xml deleted file mode 100644 index 5962164b0..000000000 --- a/app/src/main/res/drawable/keyboard_enter_background.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/keyboard_key_background.xml b/app/src/main/res/drawable/keyboard_key_background.xml deleted file mode 100644 index 97089719b..000000000 --- a/app/src/main/res/drawable/keyboard_key_background.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/keyboard_key_selector.xml b/app/src/main/res/drawable/keyboard_key_selector.xml deleted file mode 100644 index fa28753b8..000000000 --- a/app/src/main/res/drawable/keyboard_key_selector.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/keyboard_key_selector_dark.xml b/app/src/main/res/drawable/keyboard_key_selector_dark.xml deleted file mode 100644 index fdcc236f9..000000000 --- a/app/src/main/res/drawable/keyboard_key_selector_dark.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/keyboard_space_background.xml b/app/src/main/res/drawable/keyboard_space_background.xml deleted file mode 100644 index 4b6948596..000000000 --- a/app/src/main/res/drawable/keyboard_space_background.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/minikeyboard_background.xml b/app/src/main/res/drawable/minikeyboard_background.xml deleted file mode 100644 index ae8eb27a5..000000000 --- a/app/src/main/res/drawable/minikeyboard_background.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/scribe_key_background_left_rounded.xml b/app/src/main/res/drawable/scribe_key_background_left_rounded.xml deleted file mode 100644 index 683c7848e..000000000 --- a/app/src/main/res/drawable/scribe_key_background_left_rounded.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-land/keyboard_key_preview.xml b/app/src/main/res/layout-land/keyboard_key_preview.xml deleted file mode 100644 index ddcee799b..000000000 --- a/app/src/main/res/layout-land/keyboard_key_preview.xml +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/app/src/main/res/layout-land/keyboard_view_keyboard.xml b/app/src/main/res/layout-land/keyboard_view_keyboard.xml deleted file mode 100644 index 85734c5e3..000000000 --- a/app/src/main/res/layout-land/keyboard_view_keyboard.xml +++ /dev/null @@ -1,110 +0,0 @@ - - - - -