Skip to content

输入列表

1. 概述

InputList 是筷字输入法的核心数据结构,管理用户输入的字符序列、游标位置、待输入字符、补全候选项和间隔插入。v4 采用不可变数据模型,利用 Kotlin data class 的 copy() 机制实现状态更新,从根本上保证线程安全。


2. InputList 不可变数据模型

kotlin
/**
 * 输入列表状态,不可变。
 *
 * 所有状态变更通过 copy() 创建新实例,原始实例不受影响。
 * 游标位置通过 gapIndex 表示,指向 inputs 中的 GapInput 位置。
 */
data class InputList(
    val inputs: List<InputItem> = emptyList(),
    val gapIndex: Int = 0,
    val pending: PendingInput? = null,
    val mathExprNested: InputList? = null,
) {
    /** 当前游标位置的 GapInput */
    val cursorGap: InputItem.Gap
        get() = inputs.getOrElse(gapIndex) { InputItem.Gap }

    /** 可见的字符输入列表(排除 GapInput) */
    val visibleInputs: List<InputItem.Char>
        get() = inputs.filterIsInstance<InputItem.Char>()

    /** 输入文本内容 */
    val text: String
        get() = visibleInputs.joinToString("") { it.text }

    /** 是否为空 */
    val isEmpty: Boolean
        get() = inputs.all { it is InputItem.Gap }

    // === 状态变更操作(返回新实例) ===

    /** 在游标位置追加字符输入 */
    fun appendChar(char: InputItem.Char): InputList {
        val newInputs = inputs.toMutableList().apply {
            add(gapIndex, char)
            // 在新字符后插入 GapInput
            add(gapIndex + 1, InputItem.Gap)
        }
        return copy(inputs = newInputs, gapIndex = gapIndex + 2)
    }

    /** 删除游标前的一个字符 */
    fun deleteCharBeforeCursor(): InputList {
        if (gapIndex < 2) return this
        val newInputs = inputs.toMutableList().apply {
            removeAt(gapIndex - 2) // 删除字符
            removeAt(gapIndex - 2) // 删除字符后的 GapInput
        }
        return copy(inputs = newInputs, gapIndex = gapIndex - 2)
    }

    /** 移动游标到指定位置 */
    fun moveCursorTo(newGapIndex: Int): InputList {
        val clampedIndex = newGapIndex.coerceIn(0, inputs.lastIndex)
        return copy(gapIndex = clampedIndex)
    }

    /** 清空所有输入 */
    fun clean(): InputList = InputList()

    /** 设置待输入字符 */
    fun withPending(pending: PendingInput?): InputList =
        copy(pending = pending)
}

3. 输入项类型

kotlin
sealed class InputItem {
    abstract val id: String

    /** 字符输入 */
    data class Char(
        override val id: String,
        val text: String,
        val keys: List<InputKey>,
        val replacements: List<String> = emptyList(),
        val word: InputWord? = null,
        val pairSymbol: PairSymbol? = null,
    ) : InputItem() {
        val hasPair: Boolean get() = pairSymbol != null
        val hasReplacements: Boolean get() = replacements.size > 1

        fun nextReplacement(text: String): String {
            if (replacements.size <= 1) return text
            val index = replacements.indexOf(text)
            return if (index >= 0) replacements[(index + 1) % replacements.size] else replacements[0]
        }

        fun canReplace(key: InputKey.Char): Boolean = replacements.size > 1 && key.text in replacements
    }

    /** 间隔/游标位置 */
    data object Gap : InputItem() {
        override val id = "gap"
    }

    /** 数学表达式 */
    data class MathExpr(
        override val id: String,
        val nestedList: InputList,
    ) : InputItem()
}

4. 待输入字符

kotlin
/**
 * 待输入字符,表示拼音输入中尚未提交的字符序列。
 * 与 InputList 分离,因为 pending 是临时状态。
 */
data class PendingInput(
    val chars: List<InputItem.Char>,
    val completions: List<InputCompletion> = emptyList(),
    val pinyinToggles: Set<PinyinToggleType> = emptySet(),
)

5. InputWord 输入字

kotlin
sealed class InputWord {
    abstract val text: String
    abstract val frequency: Int

    /** 拼音字 */
    data class Pinyin(
        override val text: String,
        override val frequency: Int,
        val spell: String,
        val variant: String? = null,     // 繁体异体
        val tone: Int? = null,
    ) : InputWord()

    /** 拼音词组 */
    data class PinyinPhrase(
        override val text: String,
        override val frequency: Int,
        val spells: List<String>,
    ) : InputWord()

    /** Emoji */
    data class Emoji(
        override val text: String,
        override val frequency: Int = 0,
        val name: String,
        val group: String,
    ) : InputWord()

    /** 拉丁词 */
    data class Latin(
        override val text: String,
        override val frequency: Int,
    ) : InputWord()
}

6. InputCompletion 输入补全

kotlin
sealed class InputCompletion {
    abstract val text: String

    /** 拉丁词补全 */
    data class LatinWord(
        override val text: String,
        val remaining: String,
    ) : InputCompletion()

    /** 拼音词组补全 */
    data class PhraseWord(
        override val text: String,
        val remaining: String,
        val spells: List<String>,
    ) : InputCompletion()
}

7. PairSymbol 对偶符号

kotlin
data class PairSymbol(
    val open: String,    // 开启符号,如 (、[、"、'
    val close: String,   // 关闭符号,如 )、]、"、'
    val content: String? = null,  // 中间内容
)

8. 线程安全设计

不可变 data class + StateFlow 保证原子性:

kotlin
// 引擎内部的 reduce 逻辑(ImeEngine.reduce)
// 所有状态变更串行执行,StateFlow 保证原子性
private suspend fun reduce(state: ImeState, intent: ImeIntent): ImeState {
    return when (intent) {
        is ImeIntent.PressKey -> {
            // 1. 更新 InputList(StateFlow 保证原子性)
            val newInputList = state.inputList.appendChar(
                InputItem.Char(id = uuid(), text = intent.key.label, keys = listOf(intent.key))
            )
            // 2. 异步查询候选(协程,不阻塞主线程)
            val candidates = dictProvider.pinyin.queryCandidates(newInputList.pending?.text ?: "")
            // 3. 返回新状态
            state.copy(
                inputList = newInputList,
                candidateList = CandidateList(candidates = candidates),
            )
        }
        // ...
    }
}

关键保证

  • StateFlow.value 的读写是原子的
  • 所有状态变更在 reduce 中串行执行
  • 异步操作(字典查询)通过协程挂起,不阻塞主线程
  • 不可变 data class 无需同步

9. 撤销(Revoke)机制

由于 InputList 是不可变的,撤销只需保存之前的状态引用:

kotlin
class InputListEditor {
    private val undoStack = ArrayDeque<InputList>(maxSize = 50)
    private val redoStack = ArrayDeque<InputList>(maxSize = 50)

    fun pushUndo(state: InputList) {
        undoStack.addLast(state)
        redoStack.clear()
    }

    fun undo(current: InputList): InputList {
        val previous = undoStack.removeLastOrNull() ?: return current
        redoStack.addLast(current)
        return previous
    }

    fun redo(current: InputList): InputList {
        val next = redoStack.removeLastOrNull() ?: return current
        undoStack.addLast(current)
        return next
    }
}

10. 游标管理

kotlin
data class InputList(
    val inputs: List<InputItem> = emptyList(),
    val gapIndex: Int = 0,
) {
    init {
        // 编译期断言:gapIndex 在合法范围内
        require(gapIndex >= 0) { "gapIndex must be >= 0, was $gapIndex" }
        require(gapIndex <= inputs.lastIndex + 1) {
            "gapIndex must be <= ${inputs.lastIndex + 1}, was $gapIndex"
        }
    }

    /** 移动游标到左侧的 GapInput */
    fun moveCursorLeft(): InputList {
        val leftGapIndex = findPreviousGap(gapIndex)
        return if (leftGapIndex >= 0) copy(gapIndex = leftGapIndex) else this
    }

    /** 移动游标到右侧的 GapInput */
    fun moveCursorRight(): InputList {
        val rightGapIndex = findNextGap(gapIndex)
        return if (rightGapIndex >= 0) copy(gapIndex = rightGapIndex) else this
    }

    private fun findPreviousGap(fromIndex: Int): Int {
        for (i in (fromIndex - 1) downTo 0) {
            if (inputs[i] is InputItem.Gap) return i
        }
        return -1
    }

    private fun findNextGap(fromIndex: Int): Int {
        for (i in (fromIndex + 1)..inputs.lastIndex) {
            if (inputs[i] is InputItem.Gap) return i
        }
        return -1
    }
}

11. InputClip 剪贴板输入

kotlin
data class InputClip(
    val text: String,
    val type: InputTextType? = null,
) {
    companion object {
        fun from(text: String): InputClip {
            val type = InputTextType.detect(text)
            return InputClip(text, type)
        }
    }
}

enum class InputTextType {
    Text, Url, Email, Phone, Captcha, IdCard, CreditCard, Address, Html;

    companion object {
        fun detect(text: String): InputTextType? = when {
            URL_REGEX.matches(text) -> Url
            EMAIL_REGEX.matches(text) -> Email
            PHONE_REGEX.matches(text) -> Phone
            CAPTCHA_REGEX.matches(text) -> Captcha
            ID_CARD_REGEX.matches(text) -> IdCard
            CREDIT_CARD_REGEX.matches(text) -> CreditCard
            text.contains("<") && text.contains(">") -> Html
            else -> null
        }
    }
}