Skip to content
Snippets Groups Projects
KeyboardSizingCalculator.kt 19.5 KiB
Newer Older
package org.futo.inputmethod.v2keyboard

import android.content.Context
import android.graphics.Rect
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.width
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.window.layout.FoldingFeature
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encodeToString
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.encoding.encodeStructure
import kotlinx.serialization.json.Json
import org.futo.inputmethod.latin.FoldStateProvider
import org.futo.inputmethod.latin.LatinIME
import org.futo.inputmethod.latin.settings.SettingsValues
import org.futo.inputmethod.latin.uix.SettingsKey
import org.futo.inputmethod.latin.uix.UixManager
import org.futo.inputmethod.latin.uix.getSetting
import org.futo.inputmethod.latin.uix.getSettingBlocking
import org.futo.inputmethod.latin.uix.setSettingBlocking
import org.futo.inputmethod.latin.utils.ResourceUtils
import kotlin.math.roundToInt

val OldKeyboardHeightMultiplierSetting = SettingsKey(floatPreferencesKey("keyboardHeightMultiplier"), 1.0f)
val OldKeyboardBottomOffsetSetting = SettingsKey(floatPreferencesKey("keyboardOffset"), 0.0f)

interface KeyboardSizeStateProvider {
    val currentSizeState: KeyboardSizeSettingKind
}

sealed class ComputedKeyboardSize(
    val width: Int,
    val height: Int,
    val padding: Rect,
    val singleRowHeight: Int = height / 4
)
class RegularKeyboardSize(
    width: Int, height: Int, padding: Rect, singleRowHeight: Int = height / 4
) : ComputedKeyboardSize(width, height, padding, singleRowHeight)
class SplitKeyboardSize(
    width: Int, height: Int, padding: Rect, singleRowHeight: Int = height / 4,
    val splitLayoutWidth: Int
) : ComputedKeyboardSize(width, height, padding, singleRowHeight)

enum class OneHandedDirection {
    Left,
    Right
}

val OneHandedDirection.opposite: OneHandedDirection
    get() = when(this) {
        OneHandedDirection.Left -> OneHandedDirection.Right
        OneHandedDirection.Right -> OneHandedDirection.Left
    }

class OneHandedKeyboardSize(
    width: Int, height: Int, padding: Rect, singleRowHeight: Int = height / 4,
    val layoutWidth: Int, val direction: OneHandedDirection
) : ComputedKeyboardSize(width, height, padding, singleRowHeight)

class FloatingKeyboardSize(
    width: Int, height: Int, padding: Rect, singleRowHeight: Int = height / 4,
    val bottomOrigin: Pair<Int, Int>
): ComputedKeyboardSize(width, height, padding, singleRowHeight)

val ComputedKeyboardSize.totalKeyboardWidth: Int
    get() = when(this) {
        is FloatingKeyboardSize -> width - padding.left - padding.right
        is OneHandedKeyboardSize -> layoutWidth
        is RegularKeyboardSize -> width - padding.left - padding.right
        is SplitKeyboardSize -> width - padding.left - padding.right
    }

enum class KeyboardMode {
    Regular,
    Split,
    OneHanded,
    Floating
}


object DpRectSerializer : KSerializer<DpRect> {
    override val descriptor: SerialDescriptor = buildClassSerialDescriptor("DpRect") {
        element<Float>("left")
        element<Float>("top")
        element<Float>("right")
        element<Float>("bottom")
    override fun serialize(encoder: Encoder, value: DpRect) {
        encoder.encodeStructure(descriptor) {
            encodeFloatElement(descriptor, 0, value.left.value)
            encodeFloatElement(descriptor, 1, value.top.value)
            encodeFloatElement(descriptor, 2, value.right.value)
            encodeFloatElement(descriptor, 3, value.bottom.value)
    override fun deserialize(decoder: Decoder): DpRect {
        return decoder.decodeStructure(descriptor) {
            var left = 0.0f
            var top = 0.0f
            var right = 0.0f
            var bottom = 0.0f

            while (true) {
                when (val index = decodeElementIndex(descriptor)) {
                    0 -> left = decodeFloatElement(descriptor, 0)
                    1 -> top = decodeFloatElement(descriptor, 1)
                    2 -> right = decodeFloatElement(descriptor, 2)
                    3 -> bottom = decodeFloatElement(descriptor, 3)
                    CompositeDecoder.DECODE_DONE -> break
                    else -> error("Unexpected index: $index")
                }
            }

            DpRect(left = left.dp, top = top.dp, right = right.dp, bottom = bottom.dp)
typealias SDpRect = @Serializable(with = DpRectSerializer::class) DpRect

@Serializable
data class SavedKeyboardSizingSettings(
    val currentMode: KeyboardMode,
    val heightMultiplier: Float,
    val heightAdditionDp: Float = 0.0f,
    val paddingDp: SDpRect,

    // Split
    val splitWidthFraction: Float,
    val splitPaddingDp: SDpRect,
    val splitHeightAdditionDp: Float = 0.0f,
    /** One handed, values with respect to left handed mode:
     * * left = padding
     * * right = width + padding
     * * bottom = padding for bottom */
    val oneHandedDirection: OneHandedDirection,
    // bottom left of the floating keyboard, relative to bottom left of screen, .second is Y up
    val floatingBottomOriginDp: Pair<Float, Float>,
    val floatingWidthDp: Float,
) {
    fun toJsonString(): String =
        Json.encodeToString(this)

    companion object {
        @JvmStatic
        fun fromJsonString(s: String): SavedKeyboardSizingSettings? =
            try {
                Json.decodeFromString(s)
            } catch (e: Exception) {
                //e.printStackTrace()
                null
            }
    }
}

fun getDefaultSettingForKind(kind: KeyboardSizeSettingKind, context: Context): SavedKeyboardSizingSettings {
    val oldBottomOffset = context.getSettingBlocking(OldKeyboardBottomOffsetSetting).dp

    val metrics = context.resources.displayMetrics
    val density = metrics.density.toFloat()
    val minDimDp = (minOf(metrics.widthPixels, metrics.heightPixels).toFloat() / density).dp

    val oldHeightMultiplier = context.getSettingBlocking(OldKeyboardHeightMultiplierSetting) + run {
        (oldBottomOffset.value * density) / metrics.heightPixels.toFloat()
    }

    val extraSidePadding = when {
        minDimDp > 600.dp -> 24.dp
        else -> 0.dp
    }

    val portraitDeviceSizeHeightMultiplier = when {
        minDimDp > 600.dp -> 0.8f
        else -> 1.0f
    }

    val portraitSplitWidthFraction = when {
        minDimDp > 600.dp -> 3.0f / 5.0f
        else -> 4.0f / 5.0f
    }

    return when(kind) {
        KeyboardSizeSettingKind.Portrait -> SavedKeyboardSizingSettings(
            currentMode = KeyboardMode.Regular,
            heightMultiplier = 1.0f * oldHeightMultiplier * portraitDeviceSizeHeightMultiplier,
            paddingDp = DpRect(2.dp + extraSidePadding, 4.dp, 2.dp + extraSidePadding, 10.dp + oldBottomOffset),
            splitPaddingDp = DpRect(2.dp, 4.dp, 2.dp, 10.dp + oldBottomOffset),
            splitWidthFraction = portraitSplitWidthFraction,
            oneHandedDirection = OneHandedDirection.Right,
            oneHandedRectDp = DpRect(4.dp, 4.dp, 364.dp, 30.dp + oldBottomOffset),
            floatingBottomOriginDp = Pair(0.0f, 0.0f),
            floatingHeightDp = 240.0f,
            floatingWidthDp = 360.0f,
            prefersSplit = false
        )

        KeyboardSizeSettingKind.Landscape -> SavedKeyboardSizingSettings(
            currentMode = KeyboardMode.Split,
            heightMultiplier = 0.9f * oldHeightMultiplier,
            paddingDp = DpRect(8.dp + extraSidePadding, 2.dp, 8.dp + extraSidePadding, 2.dp),
            splitPaddingDp = DpRect(8.dp, 2.dp, 8.dp, 2.dp),
            splitWidthFraction = 3.0f / 5.0f,
            oneHandedDirection = OneHandedDirection.Right,
            oneHandedRectDp = DpRect(4.dp, 4.dp, 364.dp, 30.dp),
            floatingBottomOriginDp = Pair(0.0f, 0.0f),
            floatingHeightDp = 240.0f,
            floatingWidthDp = 360.0f,
            prefersSplit = true
        )

        KeyboardSizeSettingKind.FoldableInnerDisplay -> SavedKeyboardSizingSettings(
            currentMode = KeyboardMode.Split,
            heightMultiplier = 0.67f * oldHeightMultiplier,
            paddingDp = DpRect(44.dp, 4.dp, 44.dp, 8.dp),
            splitPaddingDp = DpRect(44.dp, 4.dp, 44.dp, 8.dp),
            splitWidthFraction = 3.0f / 5.0f,
            oneHandedDirection = OneHandedDirection.Right,
            oneHandedRectDp = DpRect(4.dp, 4.dp, 364.dp, 30.dp),
            floatingBottomOriginDp = Pair(0.0f, 0.0f),
            floatingHeightDp = 240.0f,
            floatingWidthDp = 360.0f,
            prefersSplit = true
        )
    }
}

enum class KeyboardSizeSettingKind {
    Portrait,
    Landscape,
    FoldableInnerDisplay
}

/** Returns whether or not FoldableInnerDisplay size kind is allowed for this device */
fun Context.isFoldableInnerDisplayAllowed(): Boolean {
    val model = Build.MODEL
    return when {
        // Samsung Galaxy Z Flip models
        model.startsWith("SM-F7") -> false

        // Samsung Galaxy Z Fold models
        model.startsWith("SM-F9") -> true

        // Pixel folds
        model == "GGH2X"
                || model == "GC15S"
                || model == "G9FPL" -> true

        // Check based on minimum width and aspect ratio
        else -> {
            val metrics = resources.displayMetrics
            val density = metrics.density.toFloat()
            val minDimDp = (minOf(metrics.widthPixels, metrics.heightPixels).toFloat() / density).dp

            val aspectRatio = maxOf(metrics.widthPixels, metrics.heightPixels).toFloat() / minOf(metrics.widthPixels, metrics.heightPixels).toFloat()

            // 1.426 is currently the widest foldable (Mate XT Ultimate)
            (minDimDp > 600.dp) && (aspectRatio < 1.5)
        }
    }
}

val KeyboardSettings = mapOf(
    KeyboardSizeSettingKind.Portrait to SettingsKey(
        stringPreferencesKey("keyboard_settings_portrait"), ""),
    KeyboardSizeSettingKind.Landscape to SettingsKey(
        stringPreferencesKey("keyboard_settings_landscape"), ""),
    KeyboardSizeSettingKind.FoldableInnerDisplay to SettingsKey(
        stringPreferencesKey("keyboard_settings_fold"), ""),
class KeyboardSizingCalculator(val context: Context, val uixManager: UixManager) {
    val sizeStateProvider = context as KeyboardSizeStateProvider
    val foldStateProvider = context as FoldStateProvider
    private fun dp(v: Number): Int =
        (v.toFloat() * context.resources.displayMetrics.density).toInt()

    private fun dp(v: Rect): Rect =
        Rect(dp(v.left), dp(v.top), dp(v.right), dp(v.bottom))
    private fun dp(v: DpRect): Rect =
        Rect(dp(v.left), dp(v.top), dp(v.right), dp(v.bottom))

    fun getSavedSettings(): SavedKeyboardSizingSettings =
        SavedKeyboardSizingSettings.fromJsonString(context.getSettingBlocking(
            KeyboardSettings[sizeStateProvider.currentSizeState]!!
        )) ?: getDefaultSettingForKind(sizeStateProvider.currentSizeState, context)
    fun editSavedSettings(transform: (SavedKeyboardSizingSettings) -> SavedKeyboardSizingSettings) {
        val sizeState = sizeStateProvider.currentSizeState
        val savedSettings = SavedKeyboardSizingSettings.fromJsonString(context.getSettingBlocking(
            KeyboardSettings[sizeState]!!
        )) ?: getDefaultSettingForKind(sizeState, context)
        val transformed = transform(savedSettings)
        if(transformed != savedSettings) {
            context.setSettingBlocking(KeyboardSettings[sizeState]!!.key, transformed.toJsonString())
        }
    }
    fun resetCurrentMode() {
        val defaultSettings = getDefaultSettingForKind(sizeStateProvider.currentSizeState, context)
        editSavedSettings {
            when(it.currentMode) {
                KeyboardMode.Regular -> it.copy(
                    heightMultiplier = defaultSettings.heightMultiplier,
                    heightAdditionDp = defaultSettings.heightAdditionDp,
                    paddingDp = defaultSettings.paddingDp
                )
                KeyboardMode.Split -> it.copy(
                    splitPaddingDp = defaultSettings.splitPaddingDp,
                    splitHeightAdditionDp = defaultSettings.splitHeightAdditionDp,
                    splitWidthFraction = defaultSettings.splitWidthFraction
                )
                KeyboardMode.OneHanded -> it.copy(
                    oneHandedRectDp = defaultSettings.oneHandedRectDp,
                    oneHandedHeightAdditionDp = defaultSettings.oneHandedHeightAdditionDp
                )
                KeyboardMode.Floating -> it.copy(
                    floatingHeightDp = defaultSettings.floatingHeightDp,
                    floatingWidthDp = defaultSettings.floatingWidthDp,
                    floatingBottomOriginDp = defaultSettings.floatingBottomOriginDp
                )
            }
        }
    }

    fun exitOneHandedMode() = editSavedSettings { it.copy(
        currentMode = if(it.prefersSplit) KeyboardMode.Split else KeyboardMode.Regular
    ) }
    fun calculate(layoutName: String, settings: SettingsValues): ComputedKeyboardSize {
        val savedSettings = getSavedSettings()

        val layout = try {
            LayoutManager.getLayout(context, layoutName)
        } catch (e: Exception) {
            e.printStackTrace()
            LayoutManager.getLayout(context, "qwerty")
        }
        val effectiveRowCount = layout.effectiveRows.size

        val displayMetrics = context.resources.displayMetrics

        val heightAddition = when(savedSettings.currentMode) {
            KeyboardMode.Regular -> dp(savedSettings.heightAdditionDp)
            KeyboardMode.Split -> dp(savedSettings.splitHeightAdditionDp)
            KeyboardMode.OneHanded -> dp(savedSettings.oneHandedHeightAdditionDp)
            KeyboardMode.Floating -> 0
        }

        val padding = when(savedSettings.currentMode) {
            KeyboardMode.Regular -> dp(savedSettings.paddingDp)
            KeyboardMode.Split -> dp(savedSettings.splitPaddingDp)
            KeyboardMode.OneHanded -> dp(savedSettings.oneHandedRectDp).let { rect ->
                when(savedSettings.oneHandedDirection) {
                    OneHandedDirection.Left -> Rect(rect.left, rect.top, rect.left, rect.bottom)
                    OneHandedDirection.Right -> Rect(rect.left, rect.top, rect.left, rect.bottom)
                }
            }
            KeyboardMode.Floating -> dp(Rect(8,8,8,8))
        }

        val singularRowHeight = ((ResourceUtils.getDefaultKeyboardHeight(context.resources) + heightAddition - padding.bottom) / 4.0) *

        val numRows = 4.0 +
                ((effectiveRowCount - 5) / 2.0).coerceAtLeast(0.0) +
                if(settings.mIsNumberRowEnabled) { 0.5 } else { 0.0 } +
                if(settings.mIsArrowRowEnabled)  { 0.8 } else { 0.0 }
        val recommendedHeight = numRows * singularRowHeight + padding.bottom


        val foldState = foldStateProvider.foldState.feature

        val window = (context as LatinIME).window.window
        val width = ResourceUtils.getDefaultKeyboardWidth(window, context.resources)

        return when {
            // Special case: 50% screen height no matter the row count or settings
            foldState != null && foldState.state == FoldingFeature.State.HALF_OPENED && foldState.orientation == FoldingFeature.Orientation.HORIZONTAL -> {
                val totalHeight = displayMetrics.heightPixels / 2 - (displayMetrics.density * 80.0f).toInt()
                val singleRowHeight = totalHeight / numRows
                SplitKeyboardSize(
                    height = totalHeight,
                    singleRowHeight = singleRowHeight.roundToInt(),
                        (displayMetrics.density * 44.0f).roundToInt(),
                        (displayMetrics.density * 50.0f).roundToInt(),
                        (displayMetrics.density * 44.0f).roundToInt(),
                        (displayMetrics.density * 12.0f).roundToInt(),
                    ),
                    splitLayoutWidth = displayMetrics.widthPixels * 3 / 5
                )

            savedSettings.currentMode == KeyboardMode.Split ->
                SplitKeyboardSize(
                    width = width,
                    height = recommendedHeight.roundToInt(),
                    singleRowHeight = singularRowHeight.roundToInt(),
                    splitLayoutWidth = (displayMetrics.widthPixels * savedSettings.splitWidthFraction).toInt()
                        .coerceIn(dp(48), displayMetrics.widthPixels * 9 / 10)
                )

            savedSettings.currentMode == KeyboardMode.OneHanded ->
                OneHandedKeyboardSize(
                    width = width,
                    height = recommendedHeight.roundToInt(),
                    singleRowHeight = singularRowHeight.roundToInt(),
                    layoutWidth = dp(savedSettings.oneHandedRectDp.width)
                        .coerceIn(dp(48), displayMetrics.widthPixels * 9 / 10),
                    direction = savedSettings.oneHandedDirection
            savedSettings.currentMode == KeyboardMode.Floating -> {
                val singularRowHeightFloat = dp(savedSettings.floatingHeightDp) / 4.0f
                val recommendedHeightFloat = singularRowHeightFloat * numRows
                FloatingKeyboardSize(
                    bottomOrigin = Pair(
                        dp(savedSettings.floatingBottomOriginDp.first),
                        dp(savedSettings.floatingBottomOriginDp.second)
                    width = dp(savedSettings.floatingWidthDp).coerceIn(dp(48), displayMetrics.widthPixels),
                    height = recommendedHeightFloat.toInt().coerceIn(dp(88), displayMetrics.heightPixels),
                    singleRowHeight = singularRowHeightFloat.roundToInt(),
                    padding = padding
            else ->
                RegularKeyboardSize(
                    width = width.coerceIn(dp(48), displayMetrics.widthPixels),
                    height = recommendedHeight.roundToInt(),
                    singleRowHeight = singularRowHeight.roundToInt(),
        }
    }

    fun calculateGap(): Float {
        val displayMetrics = context.resources.displayMetrics

        val widthDp = displayMetrics.widthPixels / displayMetrics.density
        val heightDp = displayMetrics.heightPixels / displayMetrics.density

        val minDp = Math.min(widthDp, heightDp)

        return (minDp / 100.0f).coerceIn(3.0f, 6.0f)
    }

    fun calculateSuggestionBarHeightDp(): Float {
        return 40.0f
    }

    fun calculateTotalActionBarHeightPx(): Int =
        when {
            uixManager.actionsExpanded && (uixManager.currWindowActionWindow == null) -> dp(2 * calculateSuggestionBarHeightDp())
            else -> dp(calculateSuggestionBarHeightDp())
        }