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

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.serializer
import org.futo.inputmethod.keyboard.KeyConsts
import org.futo.inputmethod.keyboard.KeyboardId
import org.futo.inputmethod.keyboard.internal.KeySpecParser
import org.futo.inputmethod.keyboard.internal.KeyboardParams
import org.futo.inputmethod.keyboard.internal.MoreKeySpec
import org.futo.inputmethod.latin.common.Constants
import org.futo.inputmethod.latin.common.StringUtils
import org.futo.inputmethod.latin.settings.LongPressKeySettings
/**
 * Width tokens for keys. Rather than explicitly specifying a width in percentage as is common in
 * other layout systems, we instead use width tokens, which eliminates the need to explicitly
 * calculate and specify width percentages for most cases.
 */
@Serializable
enum class KeyWidth {
    /**
     * Regular key width. Used for normal letters (QWERTY etc)
     *
     * ##### Width calculation
     * Simply put, the width of this is calculated by dividing the total keyboard width by the
     * maximum number of keys in a row. It is consistent across the entire keyboard except in some cases.
     *
     * For example, if a keyboard has 3 rows, with 10, 9, and 7 keys respectively,
     * the regular key width will be 100% / 10 = 10% for the entire keyboard.
     * The rows with 9 and 7 keys will receive padding by default to keep them centered due
     * to the extra space.
     *
     * There are 3 cases where regular width will be inconsistent:
     * 1. In the bottom row
     * 2. In the split layout, to maintain middle alignment
     * 3. In a row with functional keys (shift or delete), in order to maintain a minimum functional key width

    /**
     * Functional key width. Used for functional keys (Shift, Backspace, Enter, Symbols, etc)
     *
     * ##### Width calculation
     * The width of this is at least the value specified in [Keyboard.minimumFunctionalKeyWidth]
     * or, for the bottom row, [Keyboard.minimumBottomRowFunctionalKeyWidth].
     *
     * The width may be larger than the minimum if the available space is there.
     * For example on the QWERTY layout, the ZXCV row has only 7 keys at a width of 10%, using up
     * only 70% of space. The remaining 30% of space is divided among the shift and backspace,
     * meaning the functional width is 15%.
     */
    FunctionalKey,

    /**
     * Grow width. Takes up all remaining space divided evenly among all grow keys in the row.
     * Mainly used for spacebar.
     *
     * Grow keys are not supported in split layouts, and their presence can complicate width
     * calculation for functional keys and others. Avoid use when possible.
     */

    /**
     * The Custom1 width as defined in [Keyboard.overrideWidths] (values are between 0.0 and 1.0)
     */

    /**
     * The Custom2 width as defined in [Keyboard.overrideWidths] (values are between 0.0 and 1.0)
     */

    /**
     * The Custom3 width as defined in [Keyboard.overrideWidths] (values are between 0.0 and 1.0)
     */

    /**
     * The Custom4 width as defined in [Keyboard.overrideWidths] (values are between 0.0 and 1.0)
     */
/**
 * Specifies which morekeys can be automatically added to the key.
 */
@Serializable
enum class MoreKeyMode(
    val autoFromKeyspec: Boolean,
    val autoNumFromCoord: Boolean,
    val autoSymFromCoord: Boolean,
    val autoFromLanguageKey: Boolean,
    /**
     * Automatically insert morekeys from keyspec shortcuts, as well as numbers, symbols and actions
     * (if not disabled by user). These count towards KeyCoordinate.
     */
    All(true, true, true, true),
     * Only automatically insert morekeys from keyspec shortcut or language-related accents
    OnlyFromLetter(true, false, false, true),
    OnlyExplicit(false, false, false, false),
}

private fun Int.and(other: Boolean): Int {
    return if(other) { this } else { 0 }
}

@Serializable
data class LabelFlags(
    val alignHintLabelToBottom: Boolean = false,
    val alignIconToBottom: Boolean = false,
    val alignLabelOffCenter: Boolean = false,
    val hasHintLabel: Boolean = false,
    val followKeyLabelRatio: Boolean = false,
    val followKeyLetterRatio: Boolean = false,
    val followKeyLargeLetterRatio: Boolean = false,
    val autoXScale: Boolean = false,
) {
    fun getValue(): Int =
        KeyConsts.LABEL_FLAGS_ALIGN_LABEL_OFF_CENTER.and(alignLabelOffCenter) or
        KeyConsts.LABEL_FLAGS_ALIGN_HINT_LABEL_TO_BOTTOM.and(alignHintLabelToBottom) or
        KeyConsts.LABEL_FLAGS_ALIGN_ICON_TO_BOTTOM.and(alignIconToBottom) or
        KeyConsts.LABEL_FLAGS_HAS_HINT_LABEL.and(hasHintLabel) or
        KeyConsts.LABEL_FLAGS_FOLLOW_KEY_LABEL_RATIO.and(followKeyLabelRatio) or
        KeyConsts.LABEL_FLAGS_FOLLOW_KEY_LETTER_RATIO.and(followKeyLetterRatio) or
        KeyConsts.LABEL_FLAGS_FOLLOW_KEY_LARGE_LETTER_RATIO.and(followKeyLargeLetterRatio) or
        KeyConsts.LABEL_FLAGS_AUTO_X_SCALE.and(autoXScale)
/**
 * Attributes for keys.
 *
 * Values are inherited in the following order:
 * `Key.attributes > Row.attributes > Keyboard.attributes > DefaultKeyAttributes`
 */
@Serializable
data class KeyAttributes(
    val width: KeyWidth? = null,
    val style: KeyVisualStyle? = null,

    /**
     * Whether or not to anchor the key to the edges.
     *
     * When a row is not wide enough to fill 100%, padding is added to the edges of the row.
     * If there are anchored keys in the row, the padding will be added after the anchored
     * keys, keeping the anchored keys at the edge.
     */
    val anchored: Boolean? = null,

    /**
     * Whether or not to show the popup indicator when the key is pressed.
     * This is usually desirable for letters on normal layouts (QWERTY letters), but undesirable
     * for functional keys (Shift, Backspace), or certain layouts (phone layout)
     */
    val showPopup: Boolean? = null,
    val moreKeyMode: MoreKeyMode? = null,

    /**
     * Whether or not to use keyspec shortcuts.
     * For example, `$` gets automatically converted to `!text/keyspec_currency`.
     *
     * The full list of keyspec shortcuts is defined in `KeySpecShortcuts`.
     */
    val useKeySpecShortcut: Boolean? = null,
    val longPressEnabled: Boolean? = null,

    /**
     * Label flags for how the key's label (and its hint) should be presented
     */
    val labelFlags: LabelFlags? = null,

    /**
     * Whether or not the key is repeatable, intended for backspace
     */
    val repeatableEnabled: Boolean? = null,

    /**
     * Whether or not the key is automatically shiftable. If true, it automatically becomes
     * uppercased when the layout is shifted. If this is not desired, this can be set to false.
     * Shift behavior can be customized by using a [CaseSelector].
     */
    val shiftable: Boolean? = null,
) {
    fun getEffectiveAttributes(row: Row, keyboard: Keyboard): KeyAttributes {
        val attrs = if(row.isBottomRow) {
            listOf(this, row.attributes, DefaultKeyAttributes)
        } else {
            listOf(this, row.attributes, keyboard.attributes, DefaultKeyAttributes)
        }

        val effectiveWidth = resolve(attrs) { it.width }

        val defaultMoreKeyMode = if((row.isLetterRow || row.isBottomRow) && effectiveWidth == KeyWidth.Regular) {
            MoreKeyMode.All
        } else {
            MoreKeyMode.OnlyFromLetter
        }

        return KeyAttributes(
            width               = resolve(attrs) { it.width              },
            style               = resolve(attrs) { it.style              },
            anchored            = resolve(attrs) { it.anchored           },
            showPopup           = resolve(attrs) { it.showPopup          },
            moreKeyMode         = resolve(attrs) { it.moreKeyMode        } ?: defaultMoreKeyMode,
            useKeySpecShortcut  = resolve(attrs) { it.useKeySpecShortcut },
            longPressEnabled    = resolve(attrs) { it.longPressEnabled   },
            labelFlags          = resolve(attrs) { it.labelFlags         },
            repeatableEnabled   = resolve(attrs) { it.repeatableEnabled  },
            shiftable           = resolve(attrs) { it.shiftable          },
        )
    }
}

private fun<T, O> resolve(attributes: List<O>, getter: (O) -> T?): T? =
    attributes.firstNotNullOfOrNull(getter)


val DefaultKeyAttributes = KeyAttributes(
    width               = KeyWidth.Regular,
    style               = KeyVisualStyle.Normal,
    anchored            = false,
    showPopup           = true,
    moreKeyMode         = null, // Default value is calculated in getEffectiveAttributes based on other attribute values
    useKeySpecShortcut  = true,
    longPressEnabled    = false,
    labelFlags          = LabelFlags(autoXScale = true),
    repeatableEnabled   = false,
    shiftable           = true,
)


object MoreKeysListSerializer: SpacedListSerializer<String>(String.serializer(), {
    MoreKeySpec.splitKeySpecs(it)?.toList() ?: listOf()
})

@Serializable
@SerialName("base")
data class BaseKey(
    /**
     * AOSP key spec. It can contain a custom label, code, icon, output text.
     *
     * Each key specification is one of the following:
     * - Label optionally followed by keyOutputText (keyLabel|keyOutputText).
     * - Label optionally followed by code point (keyLabel|!code/code_name).
     * - Icon followed by keyOutputText (!icon/icon_name|keyOutputText).
     * - Icon followed by code point (!icon/icon_name|!code/code_name).
     *
     * Label and keyOutputText are one of the following:
     * - Literal string.
     * - Label reference represented by (!text/label_name), see {@link KeyboardTextsSet}.
     * - String resource reference represented by (!text/resource_name), see {@link KeyboardTextsSet}.
     *
     * Icon is represented by (!icon/icon_name), see {@link KeyboardIconsSet}.
     *
     * Code is one of the following:
     * - Code point presented by hexadecimal string prefixed with "0x"
     * - Code reference represented by (!code/code_name), see {@link KeyboardCodesSet}.
     *
     * Special character, comma ',' backslash '\', and bar '|' can be escaped by '\' character.
     * Note that the '\' is also parsed by XML parser and {@link MoreKeySpec#splitKeySpecs(String)}
     * as well.
     */
    /**
     * Attributes for this key. Values defined here supersede any other values. Values which are
     * not defined are inherited from the row, keyboard, or default attributes.
     */
    val attributes: KeyAttributes = KeyAttributes(),

    /**
     * More keys for this key. In YAML, it can be defined as a list or a comma-separated string.
     *
     * The values here are key specs.
     */
    val moreKeys: @Serializable(with = MoreKeysListSerializer::class) List<String> = listOf(),

    /**
     * If set, overrides a default hint from the value of moreKeys.
     */
    val hint: String? = null,
) : AbstractKey {
    override fun countsToKeyCoordinate(params: KeyboardParams, row: Row, keyboard: Keyboard): Boolean {
        val attributes = attributes.getEffectiveAttributes(row, keyboard)
        val moreKeyMode = attributes.moreKeyMode!!

        return moreKeyMode.autoNumFromCoord && moreKeyMode.autoSymFromCoord
    }

    override fun computeData(params: KeyboardParams, row: Row, keyboard: Keyboard, coordinate: KeyCoordinate): ComputedKeyData {
        val attributes = attributes.getEffectiveAttributes(row, keyboard)
        val shifted = (attributes.shiftable == true) && when(params.mId.mElementId) {
            KeyboardId.ELEMENT_SYMBOLS_SHIFTED -> true
            KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED -> true
            KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED -> true
            KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED -> true
            KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED -> true
            else -> false
        }

        val relevantSpecShortcut = if(attributes.useKeySpecShortcut != false || attributes.moreKeyMode?.autoFromKeyspec != false) {
            resolveSpecWithOptionalShortcut(spec, params.mTextsSet, coordinate)
        } else {
            null
        }

        val expandedSpec: String? = params.mTextsSet.resolveTextReference(
            if(attributes.useKeySpecShortcut != false) { relevantSpecShortcut?.get(0) } else { null }
             ?: spec
        val label = expandedSpec?.let { KeySpecParser.getLabel(it) } ?: ""
        val icon = expandedSpec?.let { KeySpecParser.getIconId(it) } ?: ""
        val code = KeySpecParser.getCode(expandedSpec)
        val outputText = KeySpecParser.getOutputText(expandedSpec)

        val moreKeyMode = attributes.moreKeyMode!!
        var moreKeysBuilder = MoreKeysBuilder(code = code, mode = moreKeyMode, coordinate = coordinate, row = row, keyboard = keyboard, params = params)
        // 1. Add layout-defined moreKeys
        moreKeysBuilder =
            moreKeysBuilder.insertMoreKeys(LongPressKeySettings.joinMoreKeys(moreKeys))
        // 2. Add moreKeys from keyspec
        if (moreKeyMode.autoFromKeyspec) {
            moreKeysBuilder =
                moreKeysBuilder.insertMoreKeys(getDefaultMoreKeysForKey(code, relevantSpecShortcut))
        }
        // 3. Add settings-defined moreKeys (numbers, symbols, actions, language, etc) in their order
        params.mId.mLongPressKeySettings.currentOrder.forEach {
            moreKeysBuilder = moreKeysBuilder.insertMoreKeys(it)
        // 4. Add special (period and comma)
        if (moreKeyMode.autoSymFromCoord) {
            moreKeysBuilder =
                moreKeysBuilder.insertMoreKeys(getSpecialFromRow(coordinate, row))
        val moreKeys = moreKeysBuilder.build(shifted)

        return ComputedKeyData(
            label = if(shifted) {
                StringUtils.toTitleCaseOfKeyLabel(label, params.mId.locale) ?: label
            } else {
                label
            },
            code = if(shifted) {
                StringUtils.toTitleCaseOfKeyCode(code, params.mId.locale)
            } else {
                code
            },
            outputText = outputText,
            width = attributes.width!!,
            icon = icon,
            style = attributes.style!!,
            anchored = attributes.anchored!!,
            showPopup = attributes.showPopup!!,
            moreKeys = moreKeys.specs,
            longPressEnabled = (attributes.longPressEnabled ?: false) || moreKeys.specs.isNotEmpty(),
            repeatable = attributes.repeatableEnabled ?: false,
            countsToKeyCoordinate = moreKeyMode.autoNumFromCoord && moreKeyMode.autoSymFromCoord,
            hint = hint ?: "",
            labelFlags = attributes.labelFlags?.getValue() ?: 0
        )
    }
}

/**
 * Case selector key. Allows specifying a different type of key depending on when the layout is
 * shifted or not.
 */
@Serializable
@SerialName("case")
data class CaseSelector(
    val normal: Key,
    val shifted: Key = normal,
    /**
     * Key to use when shifted, excluding automatic shift
     */
    val shiftedManually: Key = shifted,

     * Key to use when shift locked (caps lock), defaults to [shiftedManually]
    val shiftLocked: Key = shiftedManually,

    /**
     * Key to use when in symbols layout, defaults to [normal]. Mainly used internally for
     * [TemplateShiftKey]
     */
    val symbols: Key = normal,

    /**
     * Key to use when in symbols layout, defaults to [normal]. Mainly used internally for
     * [TemplateShiftKey]
     */
    val symbolsShifted: Key = normal
) : AbstractKey {
    private fun selectKeyFromElement(elementId: Int): Key =
        when(elementId) {
            KeyboardId.ELEMENT_ALPHABET -> normal
            KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED -> shifted
            KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED -> shiftedManually

            // KeyboardState.kt currently doesn't distinguish between these
            KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED,
            KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED -> shiftLocked

            KeyboardId.ELEMENT_SYMBOLS -> symbols
            KeyboardId.ELEMENT_SYMBOLS_SHIFTED -> symbolsShifted
            else -> normal
        }

    override fun countsToKeyCoordinate(params: KeyboardParams, row: Row, keyboard: Keyboard): Boolean =
        selectKeyFromElement(params.mId.mElementId).countsToKeyCoordinate(params, row, keyboard)

    override fun computeData(
        params: KeyboardParams,
        row: Row,
        keyboard: Keyboard,
        coordinate: KeyCoordinate
    ): ComputedKeyData? =
        selectKeyFromElement(params.mId.mElementId).computeData(params, row, keyboard, coordinate)
}

typealias Key = @Serializable(with = KeyPathSerializer::class) AbstractKey

object KeyPathSerializer : PathDependentModifier<AbstractKey>(
    KeyContextualSerializer, { path, v ->
        if(v is BaseKey) {
            v
        } else {
            v
        }
    }
)
object KeyContextualSerializer : ClassOrScalarsSerializer<AbstractKey>(
    AbstractKey.serializer(),
    { contents ->
        assert(contents.isNotEmpty()) { "Key requires at least 1 element" }
        if(contents[0].startsWith("$") && contents[0] != "$" && contents.size == 1) {
            TemplateKeys[contents[0].trimStart('$')]
                ?: throw IllegalStateException("Unknown template key $contents")
        } else {
            if(contents.size == 1) {
                BaseKey(contents[0])
            } else {
                val moreKeys = contents.subList(1, contents.size)

                BaseKey(contents[0], moreKeys = moreKeys)
            }
        }
    }
)


/**
 * Affects the background for the key. Depending on the user theme settings, backgrounds may be
 * different.
 */
@Serializable
enum class KeyVisualStyle {
    /**
     * Uses a normal key background, intended for all letters.
     */

    /**
     * Uses a slightly darker colored background, intended for functional keys (backspace, etc)
     */

    /**
     * Intended for Shift to indicate it's shiftlocked. Uses a more bright background
     */

    /**
     * Uses a bright fully rounded background, normally used for the enter key
     */

    /**
     * Depending on the key borders setting, this is either
     * the same as [Normal] (key borders enabled) or a
     * fully rounded rectangle (key borders disabled)
     */
@Serializable
@SerialName("gap")
class GapKey(val attributes: KeyAttributes = KeyAttributes()) : AbstractKey {
    override fun countsToKeyCoordinate(params: KeyboardParams, row: Row, keyboard: Keyboard): Boolean = false

    override fun computeData(
        params: KeyboardParams,
        row: Row,
        keyboard: Keyboard,
        coordinate: KeyCoordinate
    ): ComputedKeyData {
        val attributes = attributes.getEffectiveAttributes(row, keyboard)

        val moreKeyMode = attributes.moreKeyMode!!

        return ComputedKeyData(
            label = "",
            code = Constants.CODE_UNSPECIFIED,
            outputText = null,
            width = attributes.width!!,
            icon = "",
            style = KeyVisualStyle.NoBackground,
            anchored = attributes.anchored!!,
            showPopup = false,
            moreKeys = listOf(),
            longPressEnabled = false,
            repeatable = false,
            moreKeyFlags = 0,
            countsToKeyCoordinate = moreKeyMode.autoNumFromCoord && moreKeyMode.autoSymFromCoord,
            hint = "",
            labelFlags = 0
        )
    }
}