diff --git a/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt b/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt index 790630911..ae8cb8a76 100644 --- a/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt +++ b/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt @@ -100,6 +100,10 @@ class KeyHandler( handleModeChangeKey() true } + KeyboardBase.KEYCODE_FLOAT_TOGGLE -> { + ime.toggleFloatingMode() + true + } KeyboardBase.KEYCODE_SPACE -> handleSpaceKeyPress(previousWasLastKeySpace) in KeyboardBase.NAVIGATION_KEYS -> { handleNavigationKey(code) diff --git a/app/src/keyboards/java/be/scri/helpers/KeyboardLanguageMappingConstants.kt b/app/src/keyboards/java/be/scri/helpers/KeyboardLanguageMappingConstants.kt index 28984886f..9ae3997d8 100644 --- a/app/src/keyboards/java/be/scri/helpers/KeyboardLanguageMappingConstants.kt +++ b/app/src/keyboards/java/be/scri/helpers/KeyboardLanguageMappingConstants.kt @@ -49,4 +49,16 @@ object KeyboardLanguageMappingConstants { "RU" to RUInterfaceVariables.PLURAL_KEY_LBL, "SV" to SVInterfaceVariables.PLURAL_KEY_LBL, ) + + val floatPlaceholder = + mapOf( + "EN" to "Float", + "ES" to "Flotante", + "DE" to "Schweben", + "IT" to "Fluttuante", + "FR" to "Flottant", + "PT" to "Flutuante", + "RU" to "Плавающая", + "SV" to "Flytande", + ) } diff --git a/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt b/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt index 102bf5986..dd54cf087 100644 --- a/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt +++ b/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt @@ -56,6 +56,10 @@ class KeyboardUIManager( fun onCloseClicked() + fun onFloatClicked() + + fun isFloatingModeActive(): Boolean + fun onEmojiSelected(emoji: String) fun onSuggestionClicked(suggestion: String) @@ -73,6 +77,8 @@ class KeyboardUIManager( fun processLinguisticSuggestions(word: String) fun isNumericKeyboardActive(): Boolean + + fun getKeyboardWidth(): Int } var keyboardView: KeyboardView = binding.keyboardView @@ -80,6 +86,8 @@ class KeyboardUIManager( // 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 @@ -210,6 +218,7 @@ class KeyboardUIManager( 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 @@ -279,11 +288,31 @@ class KeyboardUIManager( button.backgroundTintList = ContextCompat.getColorStateList(context, R.color.theme_scribe_blue) button.setTextColor(buttonTextColor) button.textSize = GeneralKeyboardIME.SUGGESTION_SIZE + button.isAllCaps = false } - binding.translateBtn.text = translatePlaceholder[langAlias] ?: "Translate" - binding.conjugateBtn.text = conjugatePlaceholder[langAlias] ?: "Conjugate" - binding.pluralBtn.text = pluralPlaceholder[langAlias] ?: "Plural" + 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) @@ -600,7 +629,8 @@ class KeyboardUIManager( */ fun initializeKeyboard(xmlId: Int) { val enterKeyType = listener.getCurrentEnterKeyType() - keyboard = KeyboardBase(context, xmlId, enterKeyType) + val width = listener.getKeyboardWidth() + keyboard = KeyboardBase(context, xmlId, enterKeyType, width) keyboardView.setKeyboard(keyboard!!) keyboardView.mOnKeyboardActionListener = listener.onKeyboardActionListener() keyboardView.requestLayout() @@ -668,6 +698,10 @@ class KeyboardUIManager( 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 } /** diff --git a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt index 57c7e1ca8..33b2124b7 100644 --- a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt +++ b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt @@ -8,6 +8,8 @@ import android.content.Context import android.content.Intent import android.database.sqlite.SQLiteException import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.LayerDrawable import android.graphics.drawable.RippleDrawable @@ -20,7 +22,10 @@ import android.text.InputType.TYPE_CLASS_PHONE import android.text.InputType.TYPE_MASK_CLASS import android.util.Log import android.view.KeyEvent +import android.view.MotionEvent import android.view.View +import android.view.ViewGroup +import android.view.WindowManager import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo.IME_ACTION_NONE import android.view.inputmethod.EditorInfo.IME_FLAG_NO_ENTER_ACTION @@ -35,6 +40,7 @@ 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.activities.MainActivity import be.scri.databinding.InputMethodViewBinding @@ -232,7 +238,7 @@ abstract class GeneralKeyboardIME( keyboardView = uiManager.keyboardView // Initial keyboard setup. - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) + keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType, getKeyboardWidth()) keyboardView?.apply { setVibrate = getIsVibrateEnabled(applicationContext, language) @@ -245,6 +251,12 @@ abstract class GeneralKeyboardIME( currentState = ScribeState.IDLE saveConjugateModeType("none") + viewBinding.root.post { + disableParentClipping(viewBinding.root) + } + initFloatingMode() + setupFloatingDragListener() + refreshUI() return viewBinding.root @@ -272,21 +284,53 @@ abstract class GeneralKeyboardIME( */ override fun onComputeInsets(outInsets: Insets) { super.onComputeInsets(outInsets) - // Access root view via UI manager if initialized. if (this::uiManager.isInitialized) { val inputView = uiManager.binding.root if (inputView.visibility == View.VISIBLE && inputView.height > 0) { val location = IntArray(2) inputView.getLocationInWindow(location) - outInsets.visibleTopInsets = location[1] - outInsets.contentTopInsets = location[1] - outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE + + 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 = 0 + outInsets.contentTopInsets = 0 + 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() + } + } else { + outInsets.visibleTopInsets = location[1] + outInsets.contentTopInsets = location[1] + outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE + } } } } override fun onWindowShown() { super.onWindowShown() + applyFloatingModeState() applyNavBarColor() keyboardView?.setPreview = isShowPopupOnKeypressEnabled(applicationContext, language) keyboardView?.setVibrate = getIsVibrateEnabled(applicationContext, language) @@ -319,7 +363,7 @@ abstract class GeneralKeyboardIME( loadLanguageData() - keyboard = KeyboardBase(this, keyboardXml, enterKeyType) + keyboard = KeyboardBase(this, keyboardXml, enterKeyType, getKeyboardWidth()) keyboardView?.setKeyboard(keyboard!!) if (this::uiManager.isInitialized && keyboardXml == R.xml.keys_symbols) { @@ -465,7 +509,7 @@ abstract class GeneralKeyboardIME( override fun onActionUp() { if (switchToLetters) { keyboardMode = keyboardLetters - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) + keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType, getKeyboardWidth()) val editorInfo = currentInputEditorInfo if (editorInfo != null && editorInfo.inputType != InputType.TYPE_NULL && keyboard?.mShiftState != SHIFT_ON_PERMANENT) { if (currentInputConnection.getCursorCapsMode(editorInfo.inputType) != 0) { @@ -586,36 +630,66 @@ abstract class GeneralKeyboardIME( private fun applyNavBarColor() { val window = window?.window ?: return - 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) + 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) - if (Build.VERSION.SDK_INT >= 35) { WindowCompat.setDecorFitsSystemWindows(window, false) - } else { - window.navigationBarColor = Color.TRANSPARENT - } + if (Build.VERSION.SDK_INT < 35) { + window.navigationBarColor = Color.TRANSPARENT + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - window.isNavigationBarContrastEnforced = false - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } - window.decorView.setBackgroundColor(color) - val insetsController = WindowCompat.getInsetsController(window, window.decorView) - insetsController.isAppearanceLightNavigationBars = isLightColor(color) + 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 + } - if (this::uiManager.isInitialized) { - uiManager.binding.root.setBackgroundColor(color) + if (this::uiManager.isInitialized) { + if (isFloatingMode) { + uiManager.binding.root.setBackgroundColor(Color.TRANSPARENT) + // Keep drag bar and pill color in sync with dark/light mode changes + val kbBgColor = ContextCompat.getColor(this, if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color) + uiManager.binding.floatingDragBar.setBackgroundColor(kbBgColor) + // Pill: dark mode → 30% white, light mode → 25% black + 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 - view.setPadding(0, 0, 0, navBarHeight) - insets - } + 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) + uiManager.binding.root.post { + ViewCompat.requestApplyInsets(uiManager.binding.root) + } } } } @@ -732,6 +806,12 @@ abstract class GeneralKeyboardIME( moveToIdleState() } + override fun onFloatClicked() { + toggleFloatingMode() + } + + override fun isFloatingModeActive(): Boolean = isFloatingMode + override fun onEmojiSelected(emoji: String) { if (emoji.isNotEmpty()) { insertEmoji(emoji, currentInputConnection, emojiKeywords, emojiMaxKeywordLength) @@ -1214,7 +1294,7 @@ abstract class GeneralKeyboardIME( this.keyboardMode = keyboardSymbols getPrimarySymbolKeyboardLayoutXML() } - keyboard = KeyboardBase(this, keyboardXml, enterKeyType) + keyboard = KeyboardBase(this, keyboardXml, enterKeyType, getKeyboardWidth()) keyboardView!!.setKeyboard(keyboard!!) if (keyboardXml == R.xml.keys_symbols) { handleModeChange(keyboardMode, keyboardView, this) @@ -1242,7 +1322,7 @@ abstract class GeneralKeyboardIME( this.keyboardMode = keyboardLetters getKeyboardLayoutXML() } - keyboard = KeyboardBase(context, keyboardXml, enterKeyType) + keyboard = KeyboardBase(context, keyboardXml, enterKeyType, getKeyboardWidth()) if (this.keyboardMode == keyboardLetters) { val wasShifted = keyboard?.mShiftState == SHIFT_ON_ONE_CHAR || keyboard?.mShiftState == SHIFT_ON_PERMANENT if (wasShifted) { @@ -1736,6 +1816,7 @@ abstract class GeneralKeyboardIME( button.textSize = SUGGESTION_SIZE button.setOnClickListener(null) button.background = null + button.foreground = null button.setTextColor(textColor) button.setOnClickListener { currentInputConnection?.commitText("$text ", 1) @@ -1936,4 +2017,520 @@ abstract class GeneralKeyboardIME( * Disables all auto-suggestions and resets the suggestion buttons to their default, inactive state. */ fun disableAutoSuggest() = uiManager.disableAutoSuggest(language) + + // MARK: Floating Keyboard Integration + + override 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()) + } 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) + } + keyboardView?.invalidateAllKeys() + } + + var isFloatingMode: Boolean = false + private set + + private var isUpdatePending = false + private var lastAppliedFloatingMode: Boolean? = null + + fun initFloatingMode() { + isFloatingMode = PreferencesHelper.getIsFloatingModeEnabled(this, language) + lastAppliedFloatingMode = null + applyFloatingModeState() + } + + fun toggleFloatingMode() { + isFloatingMode = !isFloatingMode + PreferencesHelper.setIsFloatingModeEnabled(this, language, isFloatingMode) + // Reset the cached mode so applyFloatingModeState always treats this as a change + lastAppliedFloatingMode = null + applyFloatingModeState() + window?.window?.decorView?.requestLayout() + } + + private fun applyFloatingModeState() { + if (!this::uiManager.isInitialized) return + val card = binding.keyboardCard + val dragBar = binding.floatingDragBar + val density = resources.displayMetrics.density + val root = binding.root + val win = window?.window + + // Only recreate the keyboard when the floating mode actually changes. + // Calling applyFloatingModeState from onWindowShown should not rebuild + // the keyboard every time a text field is focused. + 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) { + setBackDisposition(BACK_DISPOSITION_ADJUST_NOTHING) + win?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + win?.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) + } else { + 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 = getKeyboardWidth() + card.layoutParams = params + } + + val scaleFactor = PreferencesHelper.getFloatingScale(this, language) + card.scaleX = scaleFactor + card.scaleY = scaleFactor + + // Setup resize corner handlers + binding.resizeHandleTopLeft.setOnTouchListener(resizeTouchListener) + binding.resizeHandleTopRight.setOnTouchListener(resizeTouchListener) + binding.resizeHandleBottomLeft.setOnTouchListener(resizeTouchListener) + binding.resizeHandleBottomRight.setOnTouchListener(resizeTouchListener) + + // Hide initially + binding.resizeHandleTopLeft.visibility = View.GONE + binding.resizeHandleTopRight.visibility = View.GONE + binding.resizeHandleBottomLeft.visibility = View.GONE + binding.resizeHandleBottomRight.visibility = View.GONE + + val isDarkMode = getIsDarkModeOrNot(this) + val kbBgColorRes = if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color + val kbBgColor = ContextCompat.getColor(this, kbBgColorRes) + + // Build a floating card background that matches the keyboard's actual theme color + 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 + + // Drag bar must match the keyboard background — not the default (always-light) color + binding.floatingDragBar.setBackgroundColor(kbBgColor) + // Pill color: visible on both dark and light keyboard backgrounds + val pillColor = if (isDarkMode) 0x4DFFFFFF.toInt() else 0x40000000.toInt() + binding.floatingDragHandle.setColorFilter(pillColor) + + dragBar.visibility = View.VISIBLE + + if (modeChanged) recreateKeyboard() + + card.post { + disableParentClipping(root) + var storedX = PreferencesHelper.getFloatingX(this, language) + var storedY = PreferencesHelper.getFloatingY(this, language) + val currentScale = PreferencesHelper.getFloatingScale(this, language) + + // Default starting position: 100dp from the bottom of the screen + if (storedY == 0f) storedY = 100f * density + + val screenWidth = resources.displayMetrics.widthPixels + val screenHeight = resources.displayMetrics.heightPixels + val cardWidth = card.width.toFloat() + val cardHeight = card.height.toFloat() + + if (cardWidth > 0f && cardHeight > 0f) { + val maxTranslationX = (screenWidth - cardWidth * currentScale) / 2f + val minTranslationX = -maxTranslationX + + val minTranslationY = 0f + val maxTranslationY = screenHeight.toFloat() - cardHeight * currentScale + + val targetX = storedX.coerceInSafe(minTranslationX, maxTranslationX) + val targetY = storedY.coerceInSafe(minTranslationY, maxTranslationY) + + updateFloatingViewsPosition(targetX, targetY, currentScale) + + val attr = win?.attributes + if (attr != null) { + attr.gravity = android.view.Gravity.TOP or android.view.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 + + val isDarkMode = getIsDarkModeOrNot(this) + val kbBgColorRes = if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color + card.background = ColorDrawable(ContextCompat.getColor(this, kbBgColorRes)) + card.elevation = 0f + card.clipToOutline = false + + dragBar.visibility = View.GONE + + if (modeChanged) recreateKeyboard() + + // Reset translations immediately so the card doesn't sit at a stale + // floating position while the window re-layouts to WRAP_CONTENT. + card.translationX = 0f + card.translationY = 0f + + binding.resizeHandleTopLeft.translationX = 0f + binding.resizeHandleTopLeft.translationY = 0f + binding.resizeHandleTopRight.translationX = 0f + binding.resizeHandleTopRight.translationY = 0f + binding.resizeHandleBottomLeft.translationX = 0f + binding.resizeHandleBottomLeft.translationY = 0f + binding.resizeHandleBottomRight.translationX = 0f + binding.resizeHandleBottomRight.translationY = 0f + + // Hide resize handles + binding.resizeHandleTopLeft.visibility = View.GONE + binding.resizeHandleTopRight.visibility = View.GONE + binding.resizeHandleBottomLeft.visibility = View.GONE + binding.resizeHandleBottomRight.visibility = View.GONE + + // Apply window attributes first, then force a layout pass to ensure + // the command options bar is fully visible after returning to docked mode. + val attr = win?.attributes + if (attr != null) { + attr.gravity = android.view.Gravity.BOTTOM + attr.x = 0 + attr.y = 0 + attr.width = ViewGroup.LayoutParams.MATCH_PARENT + attr.height = ViewGroup.LayoutParams.WRAP_CONTENT + win.attributes = attr + } + + // Post a second pass to guarantee translations are zero after the + // window has finished resizing — FLAG_LAYOUT_NO_LIMITS removal is + // async and can cause a stale layout frame where the bar is clipped. + card.post { + card.translationX = 0f + card.translationY = 0f + root.requestLayout() + } + } + applyNavBarColor() + } + + private fun updateFloatingViewsPosition( + targetX: Float, + targetY: Float, + scale: Float, + ) { + val card = binding.keyboardCard + val screenHeight = resources.displayMetrics.heightPixels.toFloat() + + val cardWidth = card.width.toFloat() + val cardHeight = card.height.toFloat() + if (cardWidth == 0f || cardHeight == 0f) return + + card.scaleX = scale + card.scaleY = scale + + val transX = targetX + val transY = (screenHeight - cardHeight * scale) / 2f - targetY + + card.translationX = transX + card.translationY = transY + + val scaleOffset = scale - 1.0f + val halfW = cardWidth / 2f + val halfH = cardHeight / 2f + + binding.resizeHandleTopLeft.translationX = transX - halfW * scaleOffset + binding.resizeHandleTopLeft.translationY = transY - halfH * scaleOffset + + binding.resizeHandleTopRight.translationX = transX + halfW * scaleOffset + binding.resizeHandleTopRight.translationY = transY - halfH * scaleOffset + + binding.resizeHandleBottomLeft.translationX = transX - halfW * scaleOffset + binding.resizeHandleBottomLeft.translationY = transY + halfH * scaleOffset + + binding.resizeHandleBottomRight.translationX = transX + halfW * scaleOffset + binding.resizeHandleBottomRight.translationY = transY + halfH * scaleOffset + } + + private fun disableParentClipping(view: View) { + var p = view.parent + while (p is ViewGroup) { + p.clipChildren = false + p.clipToPadding = false + p = p.parent + } + } + + 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 = android.os.Handler(android.os.Looper.getMainLooper()) + private val hideCornersRunnable = + Runnable { + animateHideCorners() + } + + private var initialTouchX = 0f + private var initialTouchY = 0f + private var initialScale = 1.0f + private var keyboardCenterX = 0f + private var keyboardCenterY = 0f + private var initialDistance = 0f + private var isResizing = false + + private fun showCorners() { + cornerHideHandler.removeCallbacks(hideCornersRunnable) + + val corners = + listOf( + binding.resizeHandleTopLeft, + binding.resizeHandleTopRight, + binding.resizeHandleBottomLeft, + 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( + binding.resizeHandleTopLeft, + binding.resizeHandleTopRight, + binding.resizeHandleBottomLeft, + binding.resizeHandleBottomRight, + ) + + for (corner in corners) { + corner + .animate() + .alpha(0f) + .setDuration(300) + .withEndAction { + corner.visibility = View.GONE + }.start() + } + } + + private fun applyScaleAndPosition(scale: Float) { + val card = binding.keyboardCard + val screenHeight = resources.displayMetrics.heightPixels.toFloat() + val cardHeight = card.height.toFloat() + + // Recover current logical Y from live translationY (inverse of updateFloatingViewsPosition formula) + // transY = (screenHeight - cardHeight * scale) / 2f - targetY + // => targetY = (screenHeight - cardHeight * scale) / 2f - transY + // Use the previous scale to get the consistent Y value before scale changes + val liveX = card.translationX + val liveTransY = card.translationY + val prevScale = card.scaleX + val liveY = (screenHeight - cardHeight * prevScale) / 2f - liveTransY + + updateFloatingViewsPosition(liveX, liveY, scale) + } + + private val resizeTouchListener = + View.OnTouchListener { _, event -> + if (!isFloatingMode) return@OnTouchListener false + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + isResizing = true + showCorners() + + initialTouchX = event.rawX + initialTouchY = event.rawY + initialScale = PreferencesHelper.getFloatingScale(this, language) + + val card = binding.keyboardCard + 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 currentDistance = + Math + .hypot( + (event.rawX - keyboardCenterX).toDouble(), + (event.rawY - keyboardCenterY).toDouble(), + ).toFloat() + + if (initialDistance > 0) { + var targetScale = initialScale * (currentDistance / initialDistance) + targetScale = targetScale.coerceIn(0.7f, 1.3f) + applyScaleAndPosition(targetScale) + } + true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + isResizing = false + startHideCornersTimer() + + val finalScale = binding.keyboardCard.scaleX + PreferencesHelper.setFloatingScale(this, language, finalScale) + + // Save live position so applyFloatingModeState restores it correctly + val card = binding.keyboardCard + val screenHeight = resources.displayMetrics.heightPixels.toFloat() + val cardHeight = card.height.toFloat() + val liveY = (screenHeight - cardHeight * finalScale) / 2f - card.translationY + PreferencesHelper.setFloatingX(this, language, card.translationX) + PreferencesHelper.setFloatingY(this, language, liveY) + + applyFloatingModeState() + true + } + else -> false + } + } + + @android.annotation.SuppressLint("ClickableViewAccessibility") + fun setupFloatingDragListener() { + if (!this::uiManager.isInitialized) return + + binding.floatingDragHandle.setOnTouchListener { _, event -> + if (!isFloatingMode) return@setOnTouchListener false + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + initialX = event.rawX + initialY = event.rawY + + val displayMetrics = resources.displayMetrics + val screenWidth = displayMetrics.widthPixels + val screenHeight = displayMetrics.heightPixels + val cardWidth = binding.keyboardCard.width.toFloat() + val cardHeight = binding.keyboardCard.height.toFloat() + val scaleFactor = PreferencesHelper.getFloatingScale(this@GeneralKeyboardIME, language) + + maxTranslationX = (screenWidth - cardWidth * scaleFactor) / 2f + minTranslationX = -maxTranslationX + + minTranslationY = 0f + maxTranslationY = screenHeight.toFloat() - cardHeight * scaleFactor + + initialTranslationX = PreferencesHelper.getFloatingX(this@GeneralKeyboardIME, language).coerceInSafe(minTranslationX, maxTranslationX) + initialTranslationY = PreferencesHelper.getFloatingY(this@GeneralKeyboardIME, 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 scaleFactor = PreferencesHelper.getFloatingScale(this@GeneralKeyboardIME, language) + updateFloatingViewsPosition(targetX, targetY, scaleFactor) + + 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 scaleFactor = PreferencesHelper.getFloatingScale(this@GeneralKeyboardIME, language) + updateFloatingViewsPosition(finalTargetX, finalTargetY, scaleFactor) + + PreferencesHelper.setFloatingX(this@GeneralKeyboardIME, language, finalTargetX) + PreferencesHelper.setFloatingY(this@GeneralKeyboardIME, language, finalTargetY) + + binding.root.requestLayout() + startHideCornersTimer() + true + } + else -> false + } + } + } +} + +private fun Float.coerceInSafe( + bound1: Float, + bound2: Float, +): Float { + val minVal = if (bound1 < bound2) bound1 else bound2 + val maxVal = if (bound1 > bound2) bound1 else bound2 + return this.coerceIn(minVal, maxVal) } diff --git a/app/src/main/java/be/scri/helpers/KeyboardBase.kt b/app/src/main/java/be/scri/helpers/KeyboardBase.kt index 4af94453d..e18aa8eec 100644 --- a/app/src/main/java/be/scri/helpers/KeyboardBase.kt +++ b/app/src/main/java/be/scri/helpers/KeyboardBase.kt @@ -37,6 +37,8 @@ class KeyboardBase { val keyboardLetters: Int fun isSearchBar(): Boolean + + fun isFloatingModeActive(): Boolean } /** Horizontal gap default for all rows */ @@ -81,6 +83,7 @@ class KeyboardBase { private const val WIDTH_DIVIDER = 10 const val KEYCODE_SHIFT = -1 const val KEYCODE_MODE_CHANGE = -2 + const val KEYCODE_FLOAT_TOGGLE = -10 const val KEYCODE_ENTER = -4 const val KEYCODE_DELETE = -5 const val KEYCODE_SPACE = 32 @@ -411,8 +414,9 @@ class KeyboardBase { context: Context, @XmlRes xmlLayoutResId: Int, enterKeyType: Int, + customWidth: Int? = null, ) { - mDisplayWidth = context.resources.displayMetrics.widthPixels + mDisplayWidth = customWidth ?: context.resources.displayMetrics.widthPixels mDefaultHorizontalGap = 0 mDefaultWidth = mDisplayWidth / WIDTH_DIVIDER mDefaultHeight = mDefaultWidth @@ -590,6 +594,23 @@ class KeyboardBase { key.gap = 0 } + if (!isSearchBar) { + if (key.code == KEYCODE_MODE_CHANGE) { + key.width = (mDisplayWidth * 0.115).toInt() + } else if (currentRow.mKeys.any { it.code == KEYCODE_FLOAT_TOGGLE }) { + if (key.code == ','.code) { + key.width = (mDisplayWidth * 0.075).toInt() + } else if (key.label == "_") { + key.width = (mDisplayWidth * 0.075).toInt() + } else if (key.code == KEYCODE_SPACE) { + val isLettersLayout = currentRow.mKeys.none { it.label == "_" } + if (isLettersLayout) { + key.width = (mDisplayWidth * 0.475).toInt() + } + } + } + } + mKeys!!.add(key) if (key.code == KEYCODE_ENTER) { val enterResourceId = @@ -627,6 +648,29 @@ class KeyboardBase { if (x > mMinWidth) { mMinWidth = x } + if (key.code == KEYCODE_MODE_CHANGE && !isSearchBar) { + val floatKey = Key(currentRow!!) + floatKey.code = KEYCODE_FLOAT_TOGGLE + floatKey.width = (mDisplayWidth * 0.08).toInt() + floatKey.gap = (mDisplayWidth * 0.005).toInt() + floatKey.x = x + floatKey.gap + floatKey.y = y + floatKey.height = currentRow.defaultHeight + + val isFloating = provider?.isFloatingModeActive() == true + val floatResourceId = + if (isFloating) { + R.drawable.ic_keyboard_dismiss + } else { + R.drawable.ic_float_keyboard + } + floatKey.icon = context.resources.getDrawable(floatResourceId, context.theme) + floatKey.icon?.setBounds(0, 0, floatKey.icon!!.intrinsicWidth, floatKey.icon!!.intrinsicHeight) + + mKeys!!.add(floatKey) + currentRow.mKeys.add(floatKey) + x += floatKey.gap + floatKey.width + } } else if (inRow) { inRow = false y += currentRow!!.defaultHeight diff --git a/app/src/main/java/be/scri/helpers/PreferencesHelper.kt b/app/src/main/java/be/scri/helpers/PreferencesHelper.kt index 1427bb8a0..b5bde4204 100644 --- a/app/src/main/java/be/scri/helpers/PreferencesHelper.kt +++ b/app/src/main/java/be/scri/helpers/PreferencesHelper.kt @@ -632,4 +632,79 @@ object PreferencesHelper { val sharedPref = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) return sharedPref.getBoolean(INCREASE_TEXT_SIZE, false) } + + // MARK: Floating Keyboard Preferences + + private const val FLOATING_MODE_ENABLED = "floating_mode_enabled" + private const val FLOATING_X = "floating_x" + private const val FLOATING_Y = "floating_y" + private const val FLOATING_SCALE = "floating_scale" + + fun setIsFloatingModeEnabled( + context: Context, + language: String, + enabled: Boolean, + ) { + val sharedPref = context.getSharedPreferences(SCRIBE_PREFS, Context.MODE_PRIVATE) + sharedPref.edit { putBoolean(getLanguageSpecificPreferenceKey(FLOATING_MODE_ENABLED, language), enabled) } + } + + fun getIsFloatingModeEnabled( + context: Context, + language: String, + ): Boolean { + val sharedPref = context.getSharedPreferences(SCRIBE_PREFS, Context.MODE_PRIVATE) + return sharedPref.getBoolean(getLanguageSpecificPreferenceKey(FLOATING_MODE_ENABLED, language), false) + } + + fun setFloatingX( + context: Context, + language: String, + x: Float, + ) { + val sharedPref = context.getSharedPreferences(SCRIBE_PREFS, Context.MODE_PRIVATE) + sharedPref.edit { putFloat(getLanguageSpecificPreferenceKey(FLOATING_X, language), x) } + } + + fun getFloatingX( + context: Context, + language: String, + ): Float { + val sharedPref = context.getSharedPreferences(SCRIBE_PREFS, Context.MODE_PRIVATE) + return sharedPref.getFloat(getLanguageSpecificPreferenceKey(FLOATING_X, language), 0f) + } + + fun setFloatingY( + context: Context, + language: String, + y: Float, + ) { + val sharedPref = context.getSharedPreferences(SCRIBE_PREFS, Context.MODE_PRIVATE) + sharedPref.edit { putFloat(getLanguageSpecificPreferenceKey(FLOATING_Y, language), y) } + } + + fun getFloatingY( + context: Context, + language: String, + ): Float { + val sharedPref = context.getSharedPreferences(SCRIBE_PREFS, Context.MODE_PRIVATE) + return sharedPref.getFloat(getLanguageSpecificPreferenceKey(FLOATING_Y, language), 0f) + } + + fun setFloatingScale( + context: Context, + language: String, + scale: Float, + ) { + val sharedPref = context.getSharedPreferences(SCRIBE_PREFS, Context.MODE_PRIVATE) + sharedPref.edit { putFloat(getLanguageSpecificPreferenceKey(FLOATING_SCALE, language), scale) } + } + + fun getFloatingScale( + context: Context, + language: String, + ): Float { + val sharedPref = context.getSharedPreferences(SCRIBE_PREFS, Context.MODE_PRIVATE) + return sharedPref.getFloat(getLanguageSpecificPreferenceKey(FLOATING_SCALE, language), 1.0f) + } } diff --git a/app/src/main/java/be/scri/views/KeyboardView.kt b/app/src/main/java/be/scri/views/KeyboardView.kt index fd9372cf5..16e223b96 100644 --- a/app/src/main/java/be/scri/views/KeyboardView.kt +++ b/app/src/main/java/be/scri/views/KeyboardView.kt @@ -746,6 +746,17 @@ class KeyboardView } } + 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. diff --git a/app/src/main/res/drawable/floating_keyboard_background.xml b/app/src/main/res/drawable/floating_keyboard_background.xml new file mode 100644 index 000000000..4b34336d7 --- /dev/null +++ b/app/src/main/res/drawable/floating_keyboard_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_conjugate_command.xml b/app/src/main/res/drawable/ic_conjugate_command.xml new file mode 100644 index 000000000..e23f6748c --- /dev/null +++ b/app/src/main/res/drawable/ic_conjugate_command.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 000000000..9f5cdf1c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_float_keyboard.xml b/app/src/main/res/drawable/ic_float_keyboard.xml new file mode 100644 index 000000000..308a85fc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_float_keyboard.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_dismiss.xml b/app/src/main/res/drawable/ic_keyboard_dismiss.xml new file mode 100644 index 000000000..f371a2722 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_dismiss.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_plural_command.xml b/app/src/main/res/drawable/ic_plural_command.xml new file mode 100644 index 000000000..450ca615a --- /dev/null +++ b/app/src/main/res/drawable/ic_plural_command.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_resize_corner.xml b/app/src/main/res/drawable/ic_resize_corner.xml new file mode 100644 index 000000000..868810a6d --- /dev/null +++ b/app/src/main/res/drawable/ic_resize_corner.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_translate_command.xml b/app/src/main/res/drawable/ic_translate_command.xml new file mode 100644 index 000000000..5120996c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_translate_command.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/input_method_view.xml b/app/src/main/res/layout/input_method_view.xml index f79cf8133..9f8bb26ce 100644 --- a/app/src/main/res/layout/input_method_view.xml +++ b/app/src/main/res/layout/input_method_view.xml @@ -11,7 +11,20 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/keyboard_holder" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="wrap_content" + android:clipChildren="false" + android:clipToPadding="false"> + + @@ -25,7 +38,7 @@ app:layout_constraintBottom_toTopOf="@id/keyboard_view" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="parent"> + app:layout_constraintTop_toTopOf="parent"> + + @@ -225,12 +247,12 @@ android:id="@+id/plural_btn" android:layout_width="0dp" android:layout_height="@dimen/command_button_height" - android:layout_marginEnd="@dimen/tiny_margin" android:background="@drawable/cmd_key_background_rounded" android:contentDescription="@string/command_bar" android:visibility="visible" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" + android:layout_marginEnd="@dimen/tiny_margin" app:layout_constraintStart_toEndOf="@+id/separator_3" app:layout_constraintTop_toTopOf="parent" /> @@ -673,7 +695,7 @@