diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b314ba1..d1663c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +## [0.0.7-beta.1] - 2026-10-06 + +第七个测试版:九键预编辑不再出现数字,拼音栏点哪个都管用,符号、表情、颜文字大幅补全。从 0.0.6-beta.1 覆盖安装即可,用户数据保留。 + +- 修复九键预编辑行有时显示数字(如“669”或一整串数字):没有词能读完全部数字、或首个候选只覆盖前半段时,预编辑退回了原始数字。现在任何时候都显示拼音字母:先按首个候选的读音对齐(含简拼,如 669 打出“模型”显示 mo'x),剩下的按完整音节和未打完音节的开头拼出。 +- 修复九键左侧拼音栏点只有声母的选项(如单按 6 后的 m、n)没反应:现在点了就按这个字母筛选候选(m → 没、么、们)。 +- 九键左侧符号栏改成一整块可滑动的列表:符号之间用细线分隔,上下边缘渐隐,提示可以上下滑;最下面的“+”加了圆圈。数字键盘的符号栏同样调整。 +- 九键右侧一列加“0”键:删除、重输、0、确定。 +- 表情补全为 Unicode 18.0 的全部基础表情(约 1900 个,原有顺序不变,新增的排在各分类后面);手机字体显示不了的新表情自动隐藏,不会出现方框。 +- 符号大幅扩充:中文标点、特殊符号(星、心、勾叉、音乐、天气、星座等)、颜文字增加到 100 多个,新增“拼音”(带声调字母)、“日文”(平假名、片假名)、“注音”、“制表”(制表线和方块)分类。 +- 修复颜文字在符号面板里显示不全:较长的符号改用每行 3 个的宽格子。 +- 切换键盘的五个图标统一成同一种样式(与工具栏的键盘图标同一个外框),工具页和切换键盘页的图标大小一致。 +- 新的应用图标:白底上的“IME”,I 是蓝色的文字光标,ME 为深色。桌面图标、App 首页标志、启动画面和 README 图片用同一个标志;Android 13 以上支持主题图标(单色层)。 +- 开关按 iOS 的比例重做(51×31,圆钮 27):圆钮离四边都是 2dp,开和关两种状态完全对称,带一层淡阴影,按住时朝移动方向轻轻拉长。之前关闭时圆钮贴着左边、打开时离右边 4dp。整个应用的开关都是这一个组件。 +- 修复模糊音页里关着的子开关仍显示蓝色底。 +- 偏好设置和首页对齐重做:每行的图标都在整行里上下居中;“音效”“震动手感”“震动强度”是上一行开关的子设置,不再重复放同一个图标,改为缩进到父开关的文字下面;开关、箭头、按钮的右边缘落在同一条线上;滑块轨道两端对齐标题和百分比;每行上下留白一致。首页的步骤圆圈、语音图标和“已就绪”标记统一为 32dp,几张卡片的文字列对齐;标题区不再比卡片多缩进 4dp。模糊音页等没有图标的行,分隔线改为从文字起点开始(之前比文字多缩进 44dp)。新增偏好页和模糊音页的几何测试,锁住这些规则。 +- 构建工具升级(Gradle 8.14、Android Gradle 插件 8.13、Kotlin 2.4)。 + ## [0.0.6-beta.1] - 2026-10-05 第六个测试版:Rime 词库在打包时预先编译好,安装或升级后不用再等一分钟。从 0.0.5-beta.1 覆盖安装即可,用户数据保留。 diff --git a/VERSION b/VERSION index 12e375e9..a9b673b6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.6-beta.1 +0.0.7-beta.1 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5fdf6224..c69aad80 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -201,8 +201,8 @@ dependencies { implementation(files("libs/sherpa-onnx-1.13.6.aar")) testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20260814") - androidTestImplementation("androidx.test.ext:junit:1.2.1") - androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test:runner:1.7.0") } // Kotlin 2.1 writes JVM unit-test classes to tmp/kotlin-classes while this diff --git a/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt index d0474b51..1a7d3ac0 100644 --- a/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt @@ -19,6 +19,8 @@ class InteractionPolishInstrumentedTest { @Test fun emojiRecentIsMostRecentlyUsedAndDeduplicated() { + // Start from no recents: earlier sessions on the device (or E2E runs) leave some. + EmojiRecentRepository.clear(context) EmojiRecentRepository.record(context, "😀") EmojiRecentRepository.record(context, "😂") EmojiRecentRepository.record(context, "😀") diff --git a/app/src/androidTest/java/llc/slacker/openime/PreferencesGeometryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PreferencesGeometryInstrumentedTest.kt new file mode 100644 index 00000000..7ac587fc --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PreferencesGeometryInstrumentedTest.kt @@ -0,0 +1,109 @@ +package llc.slacker.openime + +import android.view.View +import android.view.ViewGroup +import android.widget.SeekBar +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.theme.ImeGeometryTokens +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.abs + +/** + * The preferences page keeps one grid: icon tiles centred on their row, every + * label on one text column, and every trailing control ending on one line. + */ +@RunWith(AndroidJUnit4::class) +class PreferencesGeometryInstrumentedTest { + + @Test + fun rowsShareOneTextColumnAndOneTrailingEdge() { + DirectActivityHarness(ImeSettingsActivity::class.java).use { harness -> + harness.launch() + harness.awaitMain { activity -> + val root = activity.window.decorView + val rows = collect(root) { it.tag == "setting-row" }.filterIsInstance() + if (rows.size < 10 || rows.any { it.width == 0 }) return@awaitMain null + val density = activity.resources.displayMetrics.density + val slack = 1 + + val labelLefts = rows.flatMap { row -> collect(row) { it.tag == "setting-label" } }.map { left(it) } + assertTrue("Every label starts on one text column: $labelLefts", labelLefts.max() - labelLefts.min() <= slack) + val textColumn = labelLefts.min() + + rows.forEach { row -> + val trailingEdge = left(row) + row.width - row.paddingRight + collect(row) { it.tag == "setting-icon" }.forEach { icon -> + val iconCentre = top(icon) + icon.height / 2 + val rowCentre = top(row) + row.height / 2 + assertTrue("Icon is centred on its row (${row.contentDescription})", abs(iconCentre - rowCentre) <= slack) + } + collect(row) { it.tag == "toggle" }.forEach { toggle -> + assertTrue("Switch ends on the row's 16dp line", abs(left(toggle) + toggle.width - trailingEdge) <= slack) + } + collect(row) { it.tag == "setting-chevron" }.forEach { chevron -> + val glyphEnd = left(chevron) + chevron.width - (ImeGeometryTokens.CHEVRON_GLYPH_END_INSET_DP * density).toInt() + assertTrue("Chevron glyph ends on the row's 16dp line", abs(glyphEnd - trailingEdge) <= slack + 1) + } + collect(row) { it is SeekBar }.forEach { bar -> + val trackStart = left(bar) + bar.paddingLeft + val trackEnd = left(bar) + bar.width - bar.paddingRight + assertTrue("Slider track starts on the text column", abs(trackStart - textColumn) <= slack) + assertTrue("Slider track ends on the row's 16dp line", abs(trackEnd - trailingEdge) <= slack) + } + } + assertHairlinesStartOnText(root) + true + } + } + } + + @Test + fun fuzzyPageHairlinesStartOnTheRuleText() { + DirectActivityHarness(ImeSettingsActivity::class.java).use { harness -> + harness.launch() + harness.awaitMain { activity -> + val entry = collect(activity.window.decorView) { it.tag == "setting-row" && it.contentDescription?.startsWith("模糊音与智能纠错") == true } + .firstOrNull() ?: return@awaitMain null + entry.performClick() + true + } + harness.awaitMain { activity -> + val root = activity.window.decorView + val hairlines = collect(root) { it.tag == "row-hairline" } + if (hairlines.size < 8 || hairlines.any { it.width == 0 }) return@awaitMain null + assertHairlinesStartOnText(root) + true + } + } + } + + /** Each hairline starts where the text of the row above it starts. */ + private fun assertHairlinesStartOnText(root: View) { + collect(root) { it.tag == "row-hairline" }.forEach { line -> + val card = line.parent as ViewGroup + val above = card.getChildAt(card.indexOfChild(line) - 1) + val label = collect(above) { it.tag == "setting-label" }.firstOrNull() ?: return@forEach + assertTrue( + "Hairline under ${(above as? ViewGroup)?.contentDescription} starts on its text", + abs(left(line) - left(label)) <= 1, + ) + } + } + + private fun left(view: View): Int = IntArray(2).also { view.getLocationInWindow(it) }[0] + + private fun top(view: View): Int = IntArray(2).also { view.getLocationInWindow(it) }[1] + + private fun collect(root: View, match: (View) -> Boolean): List { + val found = mutableListOf() + fun walk(view: View) { + if (view.visibility != View.VISIBLE) return + if (match(view)) found += view + if (view is ViewGroup) for (index in 0 until view.childCount) walk(view.getChildAt(index)) + } + walk(root) + return found + } +} diff --git a/app/src/debug/java/llc/slacker/openime/DebugKeyboardActivity.kt b/app/src/debug/java/llc/slacker/openime/DebugKeyboardActivity.kt index a9492058..6667a97f 100644 --- a/app/src/debug/java/llc/slacker/openime/DebugKeyboardActivity.kt +++ b/app/src/debug/java/llc/slacker/openime/DebugKeyboardActivity.kt @@ -109,6 +109,9 @@ class DebugKeyboardActivity : Activity(), ImeKeyboardView.Listener, CandidateRes override fun nineKeyReadingFor(digits: String, candidate: String): List? = candidatePipeline.nineKeyReadingFor(digits, candidate) + override fun nineKeyPreviewFor(digits: String, candidate: String): List? = + candidatePipeline.nineKeyPreviewFor(digits, candidate) + override fun nineKeyPathsFor(code: String?): List = candidatePipeline.nineKeyPathsFor(code) diff --git a/app/src/main/assets/emoji/unicode_emoji.tsv b/app/src/main/assets/emoji/unicode_emoji.tsv new file mode 100644 index 00000000..030e7b12 --- /dev/null +++ b/app/src/main/assets/emoji/unicode_emoji.tsv @@ -0,0 +1,1913 @@ +# Unicode emoji 18.0, fully-qualified, no skin-tone or hair variants. +# Generated by scripts/generate_emoji_catalog.py from emoji-test.txt; do not edit. +笑脸 😀 +笑脸 😃 +笑脸 😄 +笑脸 😁 +笑脸 😆 +笑脸 😅 +笑脸 🤣 +笑脸 😂 +笑脸 🙂 +笑脸 🙃 +笑脸 🫠 +笑脸 🫫 +笑脸 😉 +笑脸 😊 +笑脸 😇 +笑脸 🥰 +笑脸 😍 +笑脸 🤩 +笑脸 😘 +笑脸 😗 +笑脸 ☺️ +笑脸 😚 +笑脸 😙 +笑脸 🥲 +笑脸 😋 +笑脸 😛 +笑脸 😜 +笑脸 🤪 +笑脸 😝 +笑脸 🤑 +笑脸 🤗 +笑脸 🤭 +笑脸 🫢 +笑脸 🫣 +笑脸 🤫 +笑脸 🤔 +笑脸 🫡 +笑脸 🤐 +笑脸 🤨 +笑脸 😐 +笑脸 😑 +笑脸 😶 +笑脸 🫥 +笑脸 😶‍🌫️ +笑脸 😏 +笑脸 😒 +笑脸 🙄 +笑脸 😬 +笑脸 😮‍💨 +笑脸 🤥 +笑脸 🫨 +笑脸 🙂‍↔️ +笑脸 🙂‍↕️ +笑脸 😌 +笑脸 😔 +笑脸 😪 +笑脸 🤤 +笑脸 😴 +笑脸 🫩 +笑脸 😷 +笑脸 🤒 +笑脸 🤕 +笑脸 🤢 +笑脸 🤮 +笑脸 🤧 +笑脸 🥵 +笑脸 🥶 +笑脸 🥴 +笑脸 😵 +笑脸 😵‍💫 +笑脸 🤯 +笑脸 🤠 +笑脸 🥳 +笑脸 🥸 +笑脸 😎 +笑脸 🤓 +笑脸 🧐 +笑脸 😕 +笑脸 🫤 +笑脸 😟 +笑脸 🙁 +笑脸 ☹️ +笑脸 😮 +笑脸 😯 +笑脸 😲 +笑脸 😳 +笑脸 🫪 +笑脸 🥺 +笑脸 🥹 +笑脸 😦 +笑脸 😧 +笑脸 😨 +笑脸 😰 +笑脸 😥 +笑脸 😢 +笑脸 😭 +笑脸 😱 +笑脸 😖 +笑脸 😣 +笑脸 😞 +笑脸 😓 +笑脸 😩 +笑脸 😫 +笑脸 🥱 +笑脸 😤 +笑脸 😡 +笑脸 😠 +笑脸 🤬 +笑脸 😈 +笑脸 👿 +笑脸 💀 +笑脸 ☠️ +笑脸 💩 +笑脸 🤡 +笑脸 👹 +笑脸 👺 +笑脸 👻 +笑脸 👽 +笑脸 👾 +笑脸 🤖 +笑脸 😺 +笑脸 😸 +笑脸 😹 +笑脸 😻 +笑脸 😼 +笑脸 😽 +笑脸 🙀 +笑脸 😿 +笑脸 😾 +笑脸 🙈 +笑脸 🙉 +笑脸 🙊 +笑脸 💌 +笑脸 💘 +笑脸 💝 +笑脸 💖 +笑脸 💗 +笑脸 💓 +笑脸 💞 +笑脸 💕 +笑脸 💟 +笑脸 ❣️ +笑脸 💔 +笑脸 ❤️‍🔥 +笑脸 ❤️‍🩹 +笑脸 ❤️ +笑脸 🩷 +笑脸 🧡 +笑脸 💛 +笑脸 💚 +笑脸 💙 +笑脸 🩵 +笑脸 💜 +笑脸 🤎 +笑脸 🖤 +笑脸 🩶 +笑脸 🤍 +笑脸 💋 +笑脸 💯 +笑脸 💢 +笑脸 🫯 +笑脸 💥 +笑脸 💫 +笑脸 💦 +笑脸 💨 +笑脸 🕳️ +笑脸 💬 +笑脸 👁️‍🗨️ +笑脸 🗨️ +笑脸 🗯️ +笑脸 💭 +笑脸 💤 +人物/手势 👋 +人物/手势 🤚 +人物/手势 🖐️ +人物/手势 ✋ +人物/手势 🖖 +人物/手势 🫱 +人物/手势 🫲 +人物/手势 🫳 +人物/手势 🫴 +人物/手势 🫷 +人物/手势 🫸 +人物/手势 👌 +人物/手势 🤌 +人物/手势 🤏 +人物/手势 ✌️ +人物/手势 🤞 +人物/手势 🫰 +人物/手势 🤟 +人物/手势 🤘 +人物/手势 🤙 +人物/手势 👈 +人物/手势 👉 +人物/手势 👆 +人物/手势 🖕 +人物/手势 👇 +人物/手势 ☝️ +人物/手势 🫵 +人物/手势 👍 +人物/手势 👎 +人物/手势 🫹 +人物/手势 🫺 +人物/手势 ✊ +人物/手势 👊 +人物/手势 🤛 +人物/手势 🤜 +人物/手势 👏 +人物/手势 🙌 +人物/手势 🫶 +人物/手势 👐 +人物/手势 🤲 +人物/手势 🤝 +人物/手势 🙏 +人物/手势 ✍️ +人物/手势 💅 +人物/手势 🤳 +人物/手势 💪 +人物/手势 🦾 +人物/手势 🦿 +人物/手势 🦵 +人物/手势 🦶 +人物/手势 👂 +人物/手势 🦻 +人物/手势 👃 +人物/手势 🧠 +人物/手势 🫀 +人物/手势 🫁 +人物/手势 🦷 +人物/手势 🦴 +人物/手势 👀 +人物/手势 👁️ +人物/手势 👅 +人物/手势 👄 +人物/手势 🫦 +人物/手势 👶 +人物/手势 🧒 +人物/手势 👦 +人物/手势 👧 +人物/手势 🧑 +人物/手势 👱 +人物/手势 👨 +人物/手势 🧔 +人物/手势 🧔‍♂️ +人物/手势 🧔‍♀️ +人物/手势 👩 +人物/手势 👱‍♀️ +人物/手势 👱‍♂️ +人物/手势 🧓 +人物/手势 👴 +人物/手势 👵 +人物/手势 🙍 +人物/手势 🙍‍♂️ +人物/手势 🙍‍♀️ +人物/手势 🙎 +人物/手势 🙎‍♂️ +人物/手势 🙎‍♀️ +人物/手势 🙅 +人物/手势 🙅‍♂️ +人物/手势 🙅‍♀️ +人物/手势 🙆 +人物/手势 🙆‍♂️ +人物/手势 🙆‍♀️ +人物/手势 💁 +人物/手势 💁‍♂️ +人物/手势 💁‍♀️ +人物/手势 🙋 +人物/手势 🙋‍♂️ +人物/手势 🙋‍♀️ +人物/手势 🧏 +人物/手势 🧏‍♂️ +人物/手势 🧏‍♀️ +人物/手势 🙇 +人物/手势 🙇‍♂️ +人物/手势 🙇‍♀️ +人物/手势 🤦 +人物/手势 🤦‍♂️ +人物/手势 🤦‍♀️ +人物/手势 🤷 +人物/手势 🤷‍♂️ +人物/手势 🤷‍♀️ +人物/手势 🧑‍⚕️ +人物/手势 👨‍⚕️ +人物/手势 👩‍⚕️ +人物/手势 🧑‍🎓 +人物/手势 👨‍🎓 +人物/手势 👩‍🎓 +人物/手势 🧑‍🏫 +人物/手势 👨‍🏫 +人物/手势 👩‍🏫 +人物/手势 🧑‍⚖️ +人物/手势 👨‍⚖️ +人物/手势 👩‍⚖️ +人物/手势 🧑‍🌾 +人物/手势 👨‍🌾 +人物/手势 👩‍🌾 +人物/手势 🧑‍🍳 +人物/手势 👨‍🍳 +人物/手势 👩‍🍳 +人物/手势 🧑‍🔧 +人物/手势 👨‍🔧 +人物/手势 👩‍🔧 +人物/手势 🧑‍🏭 +人物/手势 👨‍🏭 +人物/手势 👩‍🏭 +人物/手势 🧑‍💼 +人物/手势 👨‍💼 +人物/手势 👩‍💼 +人物/手势 🧑‍🔬 +人物/手势 👨‍🔬 +人物/手势 👩‍🔬 +人物/手势 🧑‍💻 +人物/手势 👨‍💻 +人物/手势 👩‍💻 +人物/手势 🧑‍🎤 +人物/手势 👨‍🎤 +人物/手势 👩‍🎤 +人物/手势 🧑‍🎨 +人物/手势 👨‍🎨 +人物/手势 👩‍🎨 +人物/手势 🧑‍✈️ +人物/手势 👨‍✈️ +人物/手势 👩‍✈️ +人物/手势 🧑‍🚀 +人物/手势 👨‍🚀 +人物/手势 👩‍🚀 +人物/手势 🧑‍🚒 +人物/手势 👨‍🚒 +人物/手势 👩‍🚒 +人物/手势 👮 +人物/手势 👮‍♂️ +人物/手势 👮‍♀️ +人物/手势 🕵️ +人物/手势 🕵️‍♂️ +人物/手势 🕵️‍♀️ +人物/手势 💂 +人物/手势 💂‍♂️ +人物/手势 💂‍♀️ +人物/手势 🥷 +人物/手势 👷 +人物/手势 👷‍♂️ +人物/手势 👷‍♀️ +人物/手势 🫅 +人物/手势 🤴 +人物/手势 👸 +人物/手势 👳 +人物/手势 👳‍♂️ +人物/手势 👳‍♀️ +人物/手势 👲 +人物/手势 🧕 +人物/手势 🤵 +人物/手势 🤵‍♂️ +人物/手势 🤵‍♀️ +人物/手势 👰 +人物/手势 👰‍♂️ +人物/手势 👰‍♀️ +人物/手势 🤰 +人物/手势 🫃 +人物/手势 🫄 +人物/手势 🤱 +人物/手势 👩‍🍼 +人物/手势 👨‍🍼 +人物/手势 🧑‍🍼 +人物/手势 👼 +人物/手势 🎅 +人物/手势 🤶 +人物/手势 🧑‍🎄 +人物/手势 🦸 +人物/手势 🦸‍♂️ +人物/手势 🦸‍♀️ +人物/手势 🦹 +人物/手势 🦹‍♂️ +人物/手势 🦹‍♀️ +人物/手势 🧙 +人物/手势 🧙‍♂️ +人物/手势 🧙‍♀️ +人物/手势 🧚 +人物/手势 🧚‍♂️ +人物/手势 🧚‍♀️ +人物/手势 🧛 +人物/手势 🧛‍♂️ +人物/手势 🧛‍♀️ +人物/手势 🧜 +人物/手势 🧜‍♂️ +人物/手势 🧜‍♀️ +人物/手势 🧝 +人物/手势 🧝‍♂️ +人物/手势 🧝‍♀️ +人物/手势 🧞 +人物/手势 🧞‍♂️ +人物/手势 🧞‍♀️ +人物/手势 🧟 +人物/手势 🧟‍♂️ +人物/手势 🧟‍♀️ +人物/手势 🧌 +人物/手势 🫈 +人物/手势 💆 +人物/手势 💆‍♂️ +人物/手势 💆‍♀️ +人物/手势 💇 +人物/手势 💇‍♂️ +人物/手势 💇‍♀️ +人物/手势 🚶 +人物/手势 🚶‍♂️ +人物/手势 🚶‍♀️ +人物/手势 🚶‍➡️ +人物/手势 🚶‍♀️‍➡️ +人物/手势 🚶‍♂️‍➡️ +人物/手势 🧍 +人物/手势 🧍‍♂️ +人物/手势 🧍‍♀️ +人物/手势 🧎 +人物/手势 🧎‍♂️ +人物/手势 🧎‍♀️ +人物/手势 🧎‍➡️ +人物/手势 🧎‍♀️‍➡️ +人物/手势 🧎‍♂️‍➡️ +人物/手势 🧑‍🦯 +人物/手势 🧑‍🦯‍➡️ +人物/手势 👨‍🦯 +人物/手势 👨‍🦯‍➡️ +人物/手势 👩‍🦯 +人物/手势 👩‍🦯‍➡️ +人物/手势 🧑‍🦼 +人物/手势 🧑‍🦼‍➡️ +人物/手势 👨‍🦼 +人物/手势 👨‍🦼‍➡️ +人物/手势 👩‍🦼 +人物/手势 👩‍🦼‍➡️ +人物/手势 🧑‍🦽 +人物/手势 🧑‍🦽‍➡️ +人物/手势 👨‍🦽 +人物/手势 👨‍🦽‍➡️ +人物/手势 👩‍🦽 +人物/手势 👩‍🦽‍➡️ +人物/手势 🏃 +人物/手势 🏃‍♂️ +人物/手势 🏃‍♀️ +人物/手势 🏃‍➡️ +人物/手势 🏃‍♀️‍➡️ +人物/手势 🏃‍♂️‍➡️ +人物/手势 🧑‍🩰 +人物/手势 💃 +人物/手势 🕺 +人物/手势 🕴️ +人物/手势 👯 +人物/手势 👯‍♂️ +人物/手势 👯‍♀️ +人物/手势 🧖 +人物/手势 🧖‍♂️ +人物/手势 🧖‍♀️ +人物/手势 🧗 +人物/手势 🧗‍♂️ +人物/手势 🧗‍♀️ +人物/手势 🤺 +人物/手势 🏇 +人物/手势 ⛷️ +人物/手势 🏂 +人物/手势 🏌️ +人物/手势 🏌️‍♂️ +人物/手势 🏌️‍♀️ +人物/手势 🏄 +人物/手势 🏄‍♂️ +人物/手势 🏄‍♀️ +人物/手势 🚣 +人物/手势 🚣‍♂️ +人物/手势 🚣‍♀️ +人物/手势 🏊 +人物/手势 🏊‍♂️ +人物/手势 🏊‍♀️ +人物/手势 ⛹️ +人物/手势 ⛹️‍♂️ +人物/手势 ⛹️‍♀️ +人物/手势 🏋️ +人物/手势 🏋️‍♂️ +人物/手势 🏋️‍♀️ +人物/手势 🚴 +人物/手势 🚴‍♂️ +人物/手势 🚴‍♀️ +人物/手势 🚵 +人物/手势 🚵‍♂️ +人物/手势 🚵‍♀️ +人物/手势 🤸 +人物/手势 🤸‍♂️ +人物/手势 🤸‍♀️ +人物/手势 🤼 +人物/手势 🤼‍♂️ +人物/手势 🤼‍♀️ +人物/手势 🤽 +人物/手势 🤽‍♂️ +人物/手势 🤽‍♀️ +人物/手势 🤾 +人物/手势 🤾‍♂️ +人物/手势 🤾‍♀️ +人物/手势 🤹 +人物/手势 🤹‍♂️ +人物/手势 🤹‍♀️ +人物/手势 🧘 +人物/手势 🧘‍♂️ +人物/手势 🧘‍♀️ +人物/手势 🛀 +人物/手势 🛌 +人物/手势 🧑‍🤝‍🧑 +人物/手势 👭 +人物/手势 👫 +人物/手势 👬 +人物/手势 💏 +人物/手势 👩‍❤️‍💋‍👨 +人物/手势 👨‍❤️‍💋‍👨 +人物/手势 👩‍❤️‍💋‍👩 +人物/手势 💑 +人物/手势 👩‍❤️‍👨 +人物/手势 👨‍❤️‍👨 +人物/手势 👩‍❤️‍👩 +人物/手势 👨‍👩‍👦 +人物/手势 👨‍👩‍👧 +人物/手势 👨‍👩‍👧‍👦 +人物/手势 👨‍👩‍👦‍👦 +人物/手势 👨‍👩‍👧‍👧 +人物/手势 👨‍👨‍👦 +人物/手势 👨‍👨‍👧 +人物/手势 👨‍👨‍👧‍👦 +人物/手势 👨‍👨‍👦‍👦 +人物/手势 👨‍👨‍👧‍👧 +人物/手势 👩‍👩‍👦 +人物/手势 👩‍👩‍👧 +人物/手势 👩‍👩‍👧‍👦 +人物/手势 👩‍👩‍👦‍👦 +人物/手势 👩‍👩‍👧‍👧 +人物/手势 👨‍👦 +人物/手势 👨‍👦‍👦 +人物/手势 👨‍👧 +人物/手势 👨‍👧‍👦 +人物/手势 👨‍👧‍👧 +人物/手势 👩‍👦 +人物/手势 👩‍👦‍👦 +人物/手势 👩‍👧 +人物/手势 👩‍👧‍👦 +人物/手势 👩‍👧‍👧 +人物/手势 🗣️ +人物/手势 👤 +人物/手势 👥 +人物/手势 🫂 +人物/手势 👪 +人物/手势 🧑‍🧑‍🧒 +人物/手势 🧑‍🧑‍🧒‍🧒 +人物/手势 🧑‍🧒 +人物/手势 🧑‍🧒‍🧒 +人物/手势 👣 +人物/手势 🫆 +动物/自然 🐵 +动物/自然 🐒 +动物/自然 🦍 +动物/自然 🦧 +动物/自然 🐶 +动物/自然 🐕 +动物/自然 🦮 +动物/自然 🐕‍🦺 +动物/自然 🐩 +动物/自然 🐺 +动物/自然 🦊 +动物/自然 🦝 +动物/自然 🐱 +动物/自然 🐈 +动物/自然 🐈‍⬛ +动物/自然 🦁 +动物/自然 🐯 +动物/自然 🐅 +动物/自然 🐆 +动物/自然 🐴 +动物/自然 🫎 +动物/自然 🫏 +动物/自然 🐎 +动物/自然 🦄 +动物/自然 🦓 +动物/自然 🦌 +动物/自然 🦬 +动物/自然 🐮 +动物/自然 🐂 +动物/自然 🐃 +动物/自然 🐄 +动物/自然 🐷 +动物/自然 🐖 +动物/自然 🐗 +动物/自然 🐽 +动物/自然 🐏 +动物/自然 🐑 +动物/自然 🐐 +动物/自然 🐪 +动物/自然 🐫 +动物/自然 🦙 +动物/自然 🦒 +动物/自然 🐘 +动物/自然 🦣 +动物/自然 🦏 +动物/自然 🦛 +动物/自然 🐭 +动物/自然 🐁 +动物/自然 🐀 +动物/自然 🐹 +动物/自然 🐰 +动物/自然 🐇 +动物/自然 🐿️ +动物/自然 🦫 +动物/自然 🦔 +动物/自然 🦇 +动物/自然 🐻 +动物/自然 🐻‍❄️ +动物/自然 🐨 +动物/自然 🐼 +动物/自然 🦥 +动物/自然 🦦 +动物/自然 🦨 +动物/自然 🦘 +动物/自然 🦡 +动物/自然 🐾 +动物/自然 🦃 +动物/自然 🐔 +动物/自然 🐓 +动物/自然 🐣 +动物/自然 🐤 +动物/自然 🐥 +动物/自然 🐦 +动物/自然 🐧 +动物/自然 🕊️ +动物/自然 🦅 +动物/自然 🦆 +动物/自然 🦢 +动物/自然 🦉 +动物/自然 🦤 +动物/自然 🪶 +动物/自然 🦩 +动物/自然 🦚 +动物/自然 🦜 +动物/自然 🪽 +动物/自然 🐦‍⬛ +动物/自然 🪿 +动物/自然 🐦‍🔥 +动物/自然 🐸 +动物/自然 🐊 +动物/自然 🐢 +动物/自然 🦎 +动物/自然 🐍 +动物/自然 🐲 +动物/自然 🐉 +动物/自然 🦕 +动物/自然 🦖 +动物/自然 🐳 +动物/自然 🐋 +动物/自然 🐬 +动物/自然 🫍 +动物/自然 🦭 +动物/自然 🐟 +动物/自然 🐠 +动物/自然 🐡 +动物/自然 🦈 +动物/自然 🐙 +动物/自然 🐚 +动物/自然 🪸 +动物/自然 🪼 +动物/自然 🦀 +动物/自然 🦞 +动物/自然 🦐 +动物/自然 🦑 +动物/自然 🦪 +动物/自然 🐌 +动物/自然 🦋 +动物/自然 🫌 +动物/自然 🐛 +动物/自然 🐜 +动物/自然 🐝 +动物/自然 🪲 +动物/自然 🐞 +动物/自然 🦗 +动物/自然 🪳 +动物/自然 🕷️ +动物/自然 🕸️ +动物/自然 🦂 +动物/自然 🦟 +动物/自然 🪰 +动物/自然 🪱 +动物/自然 🦠 +动物/自然 💐 +动物/自然 🌸 +动物/自然 💮 +动物/自然 🪷 +动物/自然 🏵️ +动物/自然 🌹 +动物/自然 🥀 +动物/自然 🌺 +动物/自然 🌻 +动物/自然 🌼 +动物/自然 🌷 +动物/自然 🪻 +动物/自然 🌱 +动物/自然 🪴 +动物/自然 🌲 +动物/自然 🌳 +动物/自然 🌴 +动物/自然 🌵 +动物/自然 🌾 +动物/自然 🌿 +动物/自然 ☘️ +动物/自然 🍀 +动物/自然 🍁 +动物/自然 🍂 +动物/自然 🍃 +动物/自然 🪹 +动物/自然 🪺 +动物/自然 🍄 +动物/自然 🪾 +食物/饮品 🍇 +食物/饮品 🍈 +食物/饮品 🍉 +食物/饮品 🍊 +食物/饮品 🍋 +食物/饮品 🍋‍🟩 +食物/饮品 🍌 +食物/饮品 🍍 +食物/饮品 🥭 +食物/饮品 🍎 +食物/饮品 🍏 +食物/饮品 🍐 +食物/饮品 🍑 +食物/饮品 🍒 +食物/饮品 🍓 +食物/饮品 🫐 +食物/饮品 🥝 +食物/饮品 🍅 +食物/饮品 🫒 +食物/饮品 🥥 +食物/饮品 🥑 +食物/饮品 🍆 +食物/饮品 🥔 +食物/饮品 🥕 +食物/饮品 🌽 +食物/饮品 🌶️ +食物/饮品 🫑 +食物/饮品 🥒 +食物/饮品 🫝 +食物/饮品 🥬 +食物/饮品 🥦 +食物/饮品 🧄 +食物/饮品 🧅 +食物/饮品 🥜 +食物/饮品 🫘 +食物/饮品 🌰 +食物/饮品 🫚 +食物/饮品 🫛 +食物/饮品 🍄‍🟫 +食物/饮品 🫜 +食物/饮品 🍞 +食物/饮品 🥐 +食物/饮品 🥖 +食物/饮品 🫓 +食物/饮品 🥨 +食物/饮品 🥯 +食物/饮品 🥞 +食物/饮品 🧇 +食物/饮品 🧀 +食物/饮品 🍖 +食物/饮品 🍗 +食物/饮品 🥩 +食物/饮品 🥓 +食物/饮品 🍔 +食物/饮品 🍟 +食物/饮品 🍕 +食物/饮品 🌭 +食物/饮品 🥪 +食物/饮品 🌮 +食物/饮品 🌯 +食物/饮品 🫔 +食物/饮品 🥙 +食物/饮品 🧆 +食物/饮品 🥚 +食物/饮品 🍳 +食物/饮品 🥘 +食物/饮品 🍲 +食物/饮品 🫕 +食物/饮品 🥣 +食物/饮品 🥗 +食物/饮品 🍿 +食物/饮品 🧈 +食物/饮品 🧂 +食物/饮品 🥫 +食物/饮品 🍱 +食物/饮品 🍘 +食物/饮品 🍙 +食物/饮品 🍚 +食物/饮品 🍛 +食物/饮品 🍜 +食物/饮品 🍝 +食物/饮品 🍠 +食物/饮品 🍢 +食物/饮品 🍣 +食物/饮品 🍤 +食物/饮品 🍥 +食物/饮品 🥮 +食物/饮品 🍡 +食物/饮品 🥟 +食物/饮品 🥠 +食物/饮品 🥡 +食物/饮品 🍦 +食物/饮品 🍧 +食物/饮品 🍨 +食物/饮品 🍩 +食物/饮品 🍪 +食物/饮品 🎂 +食物/饮品 🍰 +食物/饮品 🧁 +食物/饮品 🥧 +食物/饮品 🍫 +食物/饮品 🍬 +食物/饮品 🍭 +食物/饮品 🍮 +食物/饮品 🍯 +食物/饮品 🍼 +食物/饮品 🥛 +食物/饮品 ☕ +食物/饮品 🫖 +食物/饮品 🍵 +食物/饮品 🍶 +食物/饮品 🍾 +食物/饮品 🍷 +食物/饮品 🍸 +食物/饮品 🍹 +食物/饮品 🍺 +食物/饮品 🍻 +食物/饮品 🥂 +食物/饮品 🥃 +食物/饮品 🫗 +食物/饮品 🥤 +食物/饮品 🧋 +食物/饮品 🧃 +食物/饮品 🧉 +食物/饮品 🧊 +食物/饮品 🥢 +食物/饮品 🍽️ +食物/饮品 🍴 +食物/饮品 🥄 +食物/饮品 🔪 +食物/饮品 🫙 +食物/饮品 🏺 +旅行/地点 🌍 +旅行/地点 🌎 +旅行/地点 🌏 +旅行/地点 🌐 +旅行/地点 🗺️ +旅行/地点 🗾 +旅行/地点 🧭 +旅行/地点 🏔️ +旅行/地点 ⛰️ +旅行/地点 🛘 +旅行/地点 🌋 +旅行/地点 🗻 +旅行/地点 🏕️ +旅行/地点 🏖️ +旅行/地点 🏜️ +旅行/地点 🏝️ +旅行/地点 🏞️ +旅行/地点 🏟️ +旅行/地点 🏛️ +旅行/地点 🏗️ +旅行/地点 🧱 +旅行/地点 🪨 +旅行/地点 🪵 +旅行/地点 🛖 +旅行/地点 🏘️ +旅行/地点 🏚️ +旅行/地点 🏠 +旅行/地点 🏡 +旅行/地点 🏢 +旅行/地点 🏣 +旅行/地点 🏤 +旅行/地点 🏥 +旅行/地点 🏦 +旅行/地点 🏨 +旅行/地点 🏩 +旅行/地点 🏪 +旅行/地点 🏫 +旅行/地点 🏬 +旅行/地点 🏭 +旅行/地点 🏯 +旅行/地点 🏰 +旅行/地点 💒 +旅行/地点 🗼 +旅行/地点 🗽 +旅行/地点 ⛪ +旅行/地点 🕌 +旅行/地点 🛕 +旅行/地点 🕍 +旅行/地点 ⛩️ +旅行/地点 🕋 +旅行/地点 ⛲ +旅行/地点 ⛺ +旅行/地点 🌁 +旅行/地点 🌃 +旅行/地点 🏙️ +旅行/地点 🌄 +旅行/地点 🌅 +旅行/地点 🌆 +旅行/地点 🌇 +旅行/地点 🌉 +旅行/地点 ♨️ +旅行/地点 🎠 +旅行/地点 🛝 +旅行/地点 🎡 +旅行/地点 🎢 +旅行/地点 💈 +旅行/地点 🎪 +旅行/地点 🚂 +旅行/地点 🚃 +旅行/地点 🚄 +旅行/地点 🚅 +旅行/地点 🚆 +旅行/地点 🚇 +旅行/地点 🚈 +旅行/地点 🚉 +旅行/地点 🚊 +旅行/地点 🚝 +旅行/地点 🚞 +旅行/地点 🚋 +旅行/地点 🚌 +旅行/地点 🚍 +旅行/地点 🚎 +旅行/地点 🚐 +旅行/地点 🚑 +旅行/地点 🚒 +旅行/地点 🚓 +旅行/地点 🚔 +旅行/地点 🚕 +旅行/地点 🚖 +旅行/地点 🚗 +旅行/地点 🚘 +旅行/地点 🚙 +旅行/地点 🛻 +旅行/地点 🚚 +旅行/地点 🚛 +旅行/地点 🚜 +旅行/地点 🏎️ +旅行/地点 🏍️ +旅行/地点 🛵 +旅行/地点 🦽 +旅行/地点 🦼 +旅行/地点 🛺 +旅行/地点 🚲 +旅行/地点 🛴 +旅行/地点 🛹 +旅行/地点 🛼 +旅行/地点 🚏 +旅行/地点 🛣️ +旅行/地点 🛤️ +旅行/地点 🛢️ +旅行/地点 ⛽ +旅行/地点 🛞 +旅行/地点 🚨 +旅行/地点 🚥 +旅行/地点 🚦 +旅行/地点 🛑 +旅行/地点 🚧 +旅行/地点 🛙 +旅行/地点 ⚓ +旅行/地点 🛟 +旅行/地点 ⛵ +旅行/地点 🛶 +旅行/地点 🚤 +旅行/地点 🛳️ +旅行/地点 ⛴️ +旅行/地点 🛥️ +旅行/地点 🚢 +旅行/地点 ✈️ +旅行/地点 🛩️ +旅行/地点 🛫 +旅行/地点 🛬 +旅行/地点 🪂 +旅行/地点 💺 +旅行/地点 🚁 +旅行/地点 🚟 +旅行/地点 🚠 +旅行/地点 🚡 +旅行/地点 🛰️ +旅行/地点 🚀 +旅行/地点 🛸 +旅行/地点 🛎️ +旅行/地点 🧳 +旅行/地点 ⌛ +旅行/地点 ⏳ +旅行/地点 ⌚ +旅行/地点 ⏰ +旅行/地点 ⏱️ +旅行/地点 ⏲️ +旅行/地点 🕰️ +旅行/地点 🕛 +旅行/地点 🕧 +旅行/地点 🕐 +旅行/地点 🕜 +旅行/地点 🕑 +旅行/地点 🕝 +旅行/地点 🕒 +旅行/地点 🕞 +旅行/地点 🕓 +旅行/地点 🕟 +旅行/地点 🕔 +旅行/地点 🕠 +旅行/地点 🕕 +旅行/地点 🕡 +旅行/地点 🕖 +旅行/地点 🕢 +旅行/地点 🕗 +旅行/地点 🕣 +旅行/地点 🕘 +旅行/地点 🕤 +旅行/地点 🕙 +旅行/地点 🕥 +旅行/地点 🕚 +旅行/地点 🕦 +旅行/地点 🌑 +旅行/地点 🌒 +旅行/地点 🌓 +旅行/地点 🌔 +旅行/地点 🌕 +旅行/地点 🌖 +旅行/地点 🌗 +旅行/地点 🌘 +旅行/地点 🌙 +旅行/地点 🌚 +旅行/地点 🌛 +旅行/地点 🌜 +旅行/地点 🌡️ +旅行/地点 ☀️ +旅行/地点 🌝 +旅行/地点 🌞 +旅行/地点 🪐 +旅行/地点 ⭐ +旅行/地点 🌟 +旅行/地点 🌠 +旅行/地点 🌌 +旅行/地点 ☁️ +旅行/地点 ⛅ +旅行/地点 ⛈️ +旅行/地点 🌤️ +旅行/地点 🌥️ +旅行/地点 🌦️ +旅行/地点 🌧️ +旅行/地点 🌨️ +旅行/地点 🌩️ +旅行/地点 🌪️ +旅行/地点 🌫️ +旅行/地点 🌬️ +旅行/地点 🌀 +旅行/地点 🌈 +旅行/地点 🌂 +旅行/地点 ☂️ +旅行/地点 ☔ +旅行/地点 ⛱️ +旅行/地点 ⚡ +旅行/地点 ❄️ +旅行/地点 ☃️ +旅行/地点 ⛄ +旅行/地点 ☄️ +旅行/地点 🪋 +旅行/地点 🔥 +旅行/地点 💧 +旅行/地点 🌊 +活动 🎃 +活动 🎄 +活动 🎆 +活动 🎇 +活动 🧨 +活动 ✨ +活动 🎈 +活动 🎉 +活动 🎊 +活动 🎋 +活动 🎍 +活动 🎎 +活动 🎏 +活动 🎐 +活动 🎑 +活动 🧧 +活动 🎀 +活动 🎁 +活动 🎗️ +活动 🎟️ +活动 🎫 +活动 🎖️ +活动 🏆 +活动 🏅 +活动 🥇 +活动 🥈 +活动 🥉 +活动 ⚽ +活动 ⚾ +活动 🥎 +活动 🏀 +活动 🏐 +活动 🏈 +活动 🏉 +活动 🎾 +活动 🥏 +活动 🎳 +活动 🏏 +活动 🏑 +活动 🏒 +活动 🥍 +活动 🏓 +活动 🏸 +活动 🥊 +活动 🥋 +活动 🥅 +活动 ⛳ +活动 ⛸️ +活动 🎣 +活动 🤿 +活动 🎽 +活动 🎿 +活动 🛷 +活动 🥌 +活动 🎯 +活动 🪀 +活动 🪁 +活动 🔫 +活动 🎱 +活动 🔮 +活动 🪄 +活动 🎮 +活动 🕹️ +活动 🎰 +活动 🎲 +活动 🧩 +活动 🧸 +活动 🪅 +活动 🪩 +活动 🪆 +活动 ♠️ +活动 ♥️ +活动 ♦️ +活动 ♣️ +活动 ♟️ +活动 🃏 +活动 🀄 +活动 🎴 +活动 🎭 +活动 🖼️ +活动 🎨 +活动 🧵 +活动 🪡 +活动 🧶 +活动 🪢 +物品 👓 +物品 🕶️ +物品 🥽 +物品 🥼 +物品 🦺 +物品 👔 +物品 👕 +物品 👖 +物品 🧣 +物品 🧤 +物品 🧥 +物品 🧦 +物品 👗 +物品 👘 +物品 🥻 +物品 🩱 +物品 🩲 +物品 🩳 +物品 👙 +物品 👚 +物品 🪭 +物品 👛 +物品 👜 +物品 👝 +物品 🛍️ +物品 🎒 +物品 🩴 +物品 👞 +物品 👟 +物品 🥾 +物品 🥿 +物品 👠 +物品 👡 +物品 🩰 +物品 👢 +物品 🪮 +物品 👑 +物品 👒 +物品 🎩 +物品 🎓 +物品 🧢 +物品 🪖 +物品 ⛑️ +物品 📿 +物品 💄 +物品 💍 +物品 💎 +物品 🔇 +物品 🔈 +物品 🔉 +物品 🔊 +物品 📢 +物品 📣 +物品 📯 +物品 🔔 +物品 🔕 +物品 🎼 +物品 🎵 +物品 🎶 +物品 🎙️ +物品 🎚️ +物品 🎛️ +物品 🎤 +物品 🎧 +物品 📻 +物品 🎷 +物品 🎺 +物品 🪊 +物品 🪗 +物品 🎸 +物品 🎹 +物品 🎻 +物品 🪕 +物品 🥁 +物品 🪘 +物品 🪇 +物品 🪈 +物品 🪉 +物品 📱 +物品 📲 +物品 ☎️ +物品 📞 +物品 📟 +物品 📠 +物品 🔋 +物品 🪫 +物品 🔌 +物品 💻 +物品 🖥️ +物品 🖨️ +物品 ⌨️ +物品 🖱️ +物品 🖲️ +物品 💽 +物品 💾 +物品 💿 +物品 📀 +物品 🧮 +物品 🎥 +物品 🎞️ +物品 📽️ +物品 🎬 +物品 📺 +物品 📷 +物品 📸 +物品 📹 +物品 📼 +物品 🔍 +物品 🔎 +物品 🕯️ +物品 💡 +物品 🔦 +物品 🏮 +物品 🪔 +物品 📔 +物品 📕 +物品 📖 +物品 📗 +物品 📘 +物品 📙 +物品 📚 +物品 📓 +物品 📒 +物品 📃 +物品 📜 +物品 📄 +物品 📰 +物品 🗞️ +物品 📑 +物品 🔖 +物品 🏷️ +物品 🪙 +物品 💰 +物品 🪎 +物品 💴 +物品 💵 +物品 💶 +物品 💷 +物品 💸 +物品 💳 +物品 🧾 +物品 💹 +物品 ✉️ +物品 📧 +物品 📨 +物品 📩 +物品 📤 +物品 📥 +物品 📦 +物品 📫 +物品 📪 +物品 📬 +物品 📭 +物品 📮 +物品 🗳️ +物品 ✏️ +物品 ✒️ +物品 🖋️ +物品 🖊️ +物品 🖌️ +物品 🖍️ +物品 📝 +物品 🪌 +物品 💼 +物品 📁 +物品 📂 +物品 🗂️ +物品 📅 +物品 📆 +物品 🗒️ +物品 🗓️ +物品 📇 +物品 📈 +物品 📉 +物品 📊 +物品 📋 +物品 📌 +物品 📍 +物品 📎 +物品 🖇️ +物品 📏 +物品 📐 +物品 ✂️ +物品 🗃️ +物品 🗄️ +物品 🗑️ +物品 🔒 +物品 🔓 +物品 🔏 +物品 🔐 +物品 🔑 +物品 🗝️ +物品 🪍 +物品 🔨 +物品 🪓 +物品 ⛏️ +物品 ⚒️ +物品 🛠️ +物品 🗡️ +物品 ⚔️ +物品 💣 +物品 🪃 +物品 🏹 +物品 🛡️ +物品 🪚 +物品 🔧 +物品 🪛 +物品 🔩 +物品 ⚙️ +物品 🗜️ +物品 ⚖️ +物品 🦯 +物品 🔗 +物品 ⛓️‍💥 +物品 ⛓️ +物品 🪝 +物品 🧰 +物品 🧲 +物品 🪜 +物品 🪏 +物品 ⚗️ +物品 🧪 +物品 🧫 +物品 🧬 +物品 🔬 +物品 🔭 +物品 📡 +物品 💉 +物品 🩸 +物品 💊 +物品 🩹 +物品 🩼 +物品 🩺 +物品 🩻 +物品 🚪 +物品 🛗 +物品 🪞 +物品 🪟 +物品 🛏️ +物品 🛋️ +物品 🪑 +物品 🚽 +物品 🪠 +物品 🚿 +物品 🛁 +物品 🪤 +物品 🪒 +物品 🧴 +物品 🧷 +物品 🧹 +物品 🧺 +物品 🧻 +物品 🪣 +物品 🧼 +物品 🫧 +物品 🪥 +物品 🧽 +物品 🧯 +物品 🛒 +物品 🚬 +物品 ⚰️ +物品 🪦 +物品 ⚱️ +物品 🧿 +物品 🪬 +物品 🗿 +物品 🪧 +物品 🪪 +符号 🏧 +符号 🚮 +符号 🚰 +符号 ♿ +符号 🚹 +符号 🚺 +符号 🚻 +符号 🚼 +符号 🚾 +符号 🛂 +符号 🛃 +符号 🛄 +符号 🛅 +符号 ⚠️ +符号 🚸 +符号 ⛔ +符号 🚫 +符号 🚳 +符号 🚭 +符号 🚯 +符号 🚱 +符号 🚷 +符号 📵 +符号 🔞 +符号 ☢️ +符号 ☣️ +符号 ⬆️ +符号 ↗️ +符号 ➡️ +符号 ↘️ +符号 ⬇️ +符号 ↙️ +符号 ⬅️ +符号 ↖️ +符号 ↕️ +符号 ↔️ +符号 ↩️ +符号 ↪️ +符号 ⤴️ +符号 ⤵️ +符号 🔃 +符号 🔄 +符号 🔙 +符号 🔚 +符号 🔛 +符号 🔜 +符号 🔝 +符号 🛐 +符号 ⚛️ +符号 🕉️ +符号 ✡️ +符号 ☸️ +符号 ☯️ +符号 ✝️ +符号 ☦️ +符号 ☪️ +符号 ☮️ +符号 🕎 +符号 🔯 +符号 🪯 +符号 ♈ +符号 ♉ +符号 ♊ +符号 ♋ +符号 ♌ +符号 ♍ +符号 ♎ +符号 ♏ +符号 ♐ +符号 ♑ +符号 ♒ +符号 ♓ +符号 ⛎ +符号 🔀 +符号 🔁 +符号 🔂 +符号 ▶️ +符号 ⏩ +符号 ⏭️ +符号 ⏯️ +符号 ◀️ +符号 ⏪ +符号 ⏮️ +符号 🔼 +符号 ⏫ +符号 🔽 +符号 ⏬ +符号 ⏸️ +符号 ⏹️ +符号 ⏺️ +符号 ⏏️ +符号 🎦 +符号 🔅 +符号 🔆 +符号 📶 +符号 🛜 +符号 📳 +符号 📴 +符号 ♀️ +符号 ♂️ +符号 ⚧️ +符号 ✖️ +符号 ➕ +符号 ➖ +符号 ➗ +符号 🟰 +符号 ♾️ +符号 ‼️ +符号 ⁉️ +符号 ❓ +符号 ❔ +符号 ❕ +符号 ❗ +符号 〰️ +符号 💱 +符号 💲 +符号 ⚕️ +符号 ♻️ +符号 ⚜️ +符号 🔱 +符号 📛 +符号 🔰 +符号 ⭕ +符号 ✅ +符号 ☑️ +符号 ✔️ +符号 ❌ +符号 ❎ +符号 ➰ +符号 ➿ +符号 〽️ +符号 ✳️ +符号 ✴️ +符号 ❇️ +符号 ©️ +符号 ®️ +符号 ™️ +符号 🫟 +符号 #️⃣ +符号 *️⃣ +符号 0️⃣ +符号 1️⃣ +符号 2️⃣ +符号 3️⃣ +符号 4️⃣ +符号 5️⃣ +符号 6️⃣ +符号 7️⃣ +符号 8️⃣ +符号 9️⃣ +符号 🔟 +符号 🔠 +符号 🔡 +符号 🔢 +符号 🔣 +符号 🔤 +符号 🅰️ +符号 🆎 +符号 🅱️ +符号 🆑 +符号 🆒 +符号 🆓 +符号 ℹ️ +符号 🆔 +符号 Ⓜ️ +符号 🆕 +符号 🆖 +符号 🅾️ +符号 🆗 +符号 🅿️ +符号 🆘 +符号 🆙 +符号 🆚 +符号 🈁 +符号 🈂️ +符号 🈷️ +符号 🈶 +符号 🈯 +符号 🉐 +符号 🈹 +符号 🈚 +符号 🈲 +符号 🉑 +符号 🈸 +符号 🈴 +符号 🈳 +符号 ㊗️ +符号 ㊙️ +符号 🈺 +符号 🈵 +符号 🔴 +符号 🟠 +符号 🟡 +符号 🟢 +符号 🔵 +符号 🟣 +符号 🟤 +符号 ⚫ +符号 ⚪ +符号 🟥 +符号 🟧 +符号 🟨 +符号 🟩 +符号 🟦 +符号 🟪 +符号 🟫 +符号 ⬛ +符号 ⬜ +符号 ◼️ +符号 ◻️ +符号 ◾ +符号 ◽ +符号 ▪️ +符号 ▫️ +符号 🔶 +符号 🔷 +符号 🔸 +符号 🔹 +符号 🔺 +符号 🔻 +符号 💠 +符号 🔘 +符号 🔳 +符号 🔲 +旗帜 🏁 +旗帜 🚩 +旗帜 🎌 +旗帜 🏴 +旗帜 🏳️ +旗帜 🏳️‍🌈 +旗帜 🏳️‍⚧️ +旗帜 🏴‍☠️ +旗帜 🇦🇨 +旗帜 🇦🇩 +旗帜 🇦🇪 +旗帜 🇦🇫 +旗帜 🇦🇬 +旗帜 🇦🇮 +旗帜 🇦🇱 +旗帜 🇦🇲 +旗帜 🇦🇴 +旗帜 🇦🇶 +旗帜 🇦🇷 +旗帜 🇦🇸 +旗帜 🇦🇹 +旗帜 🇦🇺 +旗帜 🇦🇼 +旗帜 🇦🇽 +旗帜 🇦🇿 +旗帜 🇧🇦 +旗帜 🇧🇧 +旗帜 🇧🇩 +旗帜 🇧🇪 +旗帜 🇧🇫 +旗帜 🇧🇬 +旗帜 🇧🇭 +旗帜 🇧🇮 +旗帜 🇧🇯 +旗帜 🇧🇱 +旗帜 🇧🇲 +旗帜 🇧🇳 +旗帜 🇧🇴 +旗帜 🇧🇶 +旗帜 🇧🇷 +旗帜 🇧🇸 +旗帜 🇧🇹 +旗帜 🇧🇻 +旗帜 🇧🇼 +旗帜 🇧🇾 +旗帜 🇧🇿 +旗帜 🇨🇦 +旗帜 🇨🇨 +旗帜 🇨🇩 +旗帜 🇨🇫 +旗帜 🇨🇬 +旗帜 🇨🇭 +旗帜 🇨🇮 +旗帜 🇨🇰 +旗帜 🇨🇱 +旗帜 🇨🇲 +旗帜 🇨🇳 +旗帜 🇨🇴 +旗帜 🇨🇵 +旗帜 🇨🇶 +旗帜 🇨🇷 +旗帜 🇨🇺 +旗帜 🇨🇻 +旗帜 🇨🇼 +旗帜 🇨🇽 +旗帜 🇨🇾 +旗帜 🇨🇿 +旗帜 🇩🇪 +旗帜 🇩🇬 +旗帜 🇩🇯 +旗帜 🇩🇰 +旗帜 🇩🇲 +旗帜 🇩🇴 +旗帜 🇩🇿 +旗帜 🇪🇦 +旗帜 🇪🇨 +旗帜 🇪🇪 +旗帜 🇪🇬 +旗帜 🇪🇭 +旗帜 🇪🇷 +旗帜 🇪🇸 +旗帜 🇪🇹 +旗帜 🇪🇺 +旗帜 🇫🇮 +旗帜 🇫🇯 +旗帜 🇫🇰 +旗帜 🇫🇲 +旗帜 🇫🇴 +旗帜 🇫🇷 +旗帜 🇬🇦 +旗帜 🇬🇧 +旗帜 🇬🇩 +旗帜 🇬🇪 +旗帜 🇬🇫 +旗帜 🇬🇬 +旗帜 🇬🇭 +旗帜 🇬🇮 +旗帜 🇬🇱 +旗帜 🇬🇲 +旗帜 🇬🇳 +旗帜 🇬🇵 +旗帜 🇬🇶 +旗帜 🇬🇷 +旗帜 🇬🇸 +旗帜 🇬🇹 +旗帜 🇬🇺 +旗帜 🇬🇼 +旗帜 🇬🇾 +旗帜 🇭🇰 +旗帜 🇭🇲 +旗帜 🇭🇳 +旗帜 🇭🇷 +旗帜 🇭🇹 +旗帜 🇭🇺 +旗帜 🇮🇨 +旗帜 🇮🇩 +旗帜 🇮🇪 +旗帜 🇮🇱 +旗帜 🇮🇲 +旗帜 🇮🇳 +旗帜 🇮🇴 +旗帜 🇮🇶 +旗帜 🇮🇷 +旗帜 🇮🇸 +旗帜 🇮🇹 +旗帜 🇯🇪 +旗帜 🇯🇲 +旗帜 🇯🇴 +旗帜 🇯🇵 +旗帜 🇰🇪 +旗帜 🇰🇬 +旗帜 🇰🇭 +旗帜 🇰🇮 +旗帜 🇰🇲 +旗帜 🇰🇳 +旗帜 🇰🇵 +旗帜 🇰🇷 +旗帜 🇰🇼 +旗帜 🇰🇾 +旗帜 🇰🇿 +旗帜 🇱🇦 +旗帜 🇱🇧 +旗帜 🇱🇨 +旗帜 🇱🇮 +旗帜 🇱🇰 +旗帜 🇱🇷 +旗帜 🇱🇸 +旗帜 🇱🇹 +旗帜 🇱🇺 +旗帜 🇱🇻 +旗帜 🇱🇾 +旗帜 🇲🇦 +旗帜 🇲🇨 +旗帜 🇲🇩 +旗帜 🇲🇪 +旗帜 🇲🇫 +旗帜 🇲🇬 +旗帜 🇲🇭 +旗帜 🇲🇰 +旗帜 🇲🇱 +旗帜 🇲🇲 +旗帜 🇲🇳 +旗帜 🇲🇴 +旗帜 🇲🇵 +旗帜 🇲🇶 +旗帜 🇲🇷 +旗帜 🇲🇸 +旗帜 🇲🇹 +旗帜 🇲🇺 +旗帜 🇲🇻 +旗帜 🇲🇼 +旗帜 🇲🇽 +旗帜 🇲🇾 +旗帜 🇲🇿 +旗帜 🇳🇦 +旗帜 🇳🇨 +旗帜 🇳🇪 +旗帜 🇳🇫 +旗帜 🇳🇬 +旗帜 🇳🇮 +旗帜 🇳🇱 +旗帜 🇳🇴 +旗帜 🇳🇵 +旗帜 🇳🇷 +旗帜 🇳🇺 +旗帜 🇳🇿 +旗帜 🇴🇲 +旗帜 🇵🇦 +旗帜 🇵🇪 +旗帜 🇵🇫 +旗帜 🇵🇬 +旗帜 🇵🇭 +旗帜 🇵🇰 +旗帜 🇵🇱 +旗帜 🇵🇲 +旗帜 🇵🇳 +旗帜 🇵🇷 +旗帜 🇵🇸 +旗帜 🇵🇹 +旗帜 🇵🇼 +旗帜 🇵🇾 +旗帜 🇶🇦 +旗帜 🇷🇪 +旗帜 🇷🇴 +旗帜 🇷🇸 +旗帜 🇷🇺 +旗帜 🇷🇼 +旗帜 🇸🇦 +旗帜 🇸🇧 +旗帜 🇸🇨 +旗帜 🇸🇩 +旗帜 🇸🇪 +旗帜 🇸🇬 +旗帜 🇸🇭 +旗帜 🇸🇮 +旗帜 🇸🇯 +旗帜 🇸🇰 +旗帜 🇸🇱 +旗帜 🇸🇲 +旗帜 🇸🇳 +旗帜 🇸🇴 +旗帜 🇸🇷 +旗帜 🇸🇸 +旗帜 🇸🇹 +旗帜 🇸🇻 +旗帜 🇸🇽 +旗帜 🇸🇾 +旗帜 🇸🇿 +旗帜 🇹🇦 +旗帜 🇹🇨 +旗帜 🇹🇩 +旗帜 🇹🇫 +旗帜 🇹🇬 +旗帜 🇹🇭 +旗帜 🇹🇯 +旗帜 🇹🇰 +旗帜 🇹🇱 +旗帜 🇹🇲 +旗帜 🇹🇳 +旗帜 🇹🇴 +旗帜 🇹🇷 +旗帜 🇹🇹 +旗帜 🇹🇻 +旗帜 🇹🇼 +旗帜 🇹🇿 +旗帜 🇺🇦 +旗帜 🇺🇬 +旗帜 🇺🇲 +旗帜 🇺🇳 +旗帜 🇺🇸 +旗帜 🇺🇾 +旗帜 🇺🇿 +旗帜 🇻🇦 +旗帜 🇻🇨 +旗帜 🇻🇪 +旗帜 🇻🇬 +旗帜 🇻🇮 +旗帜 🇻🇳 +旗帜 🇻🇺 +旗帜 🇼🇫 +旗帜 🇼🇸 +旗帜 🇽🇰 +旗帜 🇾🇪 +旗帜 🇾🇹 +旗帜 🇿🇦 +旗帜 🇿🇲 +旗帜 🇿🇼 +旗帜 🏴󠁧󠁢󠁥󠁮󠁧󠁿 +旗帜 🏴󠁧󠁢󠁳󠁣󠁴󠁿 +旗帜 🏴󠁧󠁢󠁷󠁬󠁳󠁿 diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt index 66fe0133..fefabdfd 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt @@ -93,7 +93,11 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { super.onSaveInstanceState(outState) } + // Android 13+ gets the back gesture through the OnBackInvokedCallback + // registered in onCreate; this override only serves Android 12 and below, + // where back still arrives here. The app does not use AndroidX. @Deprecated("Deprecated in Java") + @android.annotation.SuppressLint("GestureBackNavigation") override fun onBackPressed() { handleBack() } diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index af694044..d4496d30 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -30,6 +30,7 @@ import llc.slacker.openime.candidate.Stroke import llc.slacker.openime.candidate.StrokeLexicon import llc.slacker.openime.candidate.personalizedLearningAllowed import llc.slacker.openime.core.CrashGuard +import llc.slacker.openime.core.EmojiCatalog import llc.slacker.openime.core.ImeState import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel @@ -200,6 +201,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can runCatching { CandidatePipeline(CandidateEngine(PinyinLexicon.load(this))) } .onSuccess { candidatePipeline = it } .onFailure { Log.e(TAG, "lexicon/decoder initialisation failed; running on Rime only", it) } + // The emoji panel's full list checks every emoji against the font: do it here, not on first open. + runCatching { EmojiCatalog.byCategory(this) } // Only someone who uses the 笔画 keyboard pays for its table; the // keyboard loads it on first use otherwise. if (ImeSettingsRepository.loadPreferredChineseMode(this) == KeyboardMode.STROKE) { @@ -258,6 +261,9 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun nineKeyReadingFor(digits: String, candidate: String): List? = candidatePipeline.nineKeyReadingFor(digits, candidate) + override fun nineKeyPreviewFor(digits: String, candidate: String): List? = + candidatePipeline.nineKeyPreviewFor(digits, candidate) + override fun nineKeyPathsFor(code: String?): List = candidatePipeline.nineKeyPathsFor(code) diff --git a/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt b/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt index 9594729f..dc32a1b2 100644 --- a/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt +++ b/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt @@ -76,7 +76,11 @@ class QuickPhraseEditActivity : Activity() { } } + // Android 13+ gets the back gesture through the OnBackInvokedCallback + // registered in onCreate; this override only serves Android 12 and below, + // where back still arrives here. The app does not use AndroidX. @Deprecated("Deprecated in Java") + @android.annotation.SuppressLint("GestureBackNavigation") override fun onBackPressed() { requestClose() } diff --git a/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt b/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt index 727c6798..49ac9c8f 100644 --- a/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt +++ b/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt @@ -66,7 +66,11 @@ class SymbolManagerActivity : Activity() { } } + // Android 13+ gets the back gesture through the OnBackInvokedCallback + // registered in onCreate; this override only serves Android 12 and below, + // where back still arrives here. The app does not use AndroidX. @Deprecated("Deprecated in Java") + @android.annotation.SuppressLint("GestureBackNavigation") override fun onBackPressed() { requestClose() } diff --git a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt index c6184bf7..7f2a6f63 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt @@ -36,6 +36,13 @@ interface CandidateResolver { /** Syllables of [candidate] if it spells exactly all of [digits] (a lone key may be its initial); null otherwise. */ fun nineKeyReadingFor(digits: String, candidate: String): List? = null + /** + * Pre-edit syllables for [digits] given the top [candidate]: its reading + * when it spells them all, else the part it spells (its last syllable cut + * where the digits end) followed by a spelling of the rest. Never digits. + */ + fun nineKeyPreviewFor(digits: String, candidate: String): List? = null + /** Readings of [digits] for the left rail, best first (see [NineKeyReading]). */ fun nineKeyReadingsFor(digits: String, preferred: String?): List = emptyList() @@ -270,6 +277,9 @@ class CandidatePipeline internal constructor( override fun nineKeyReadingFor(digits: String, candidate: String): List? = nineKeyDecoder.readingFor(digits, candidate) + override fun nineKeyPreviewFor(digits: String, candidate: String): List? = + nineKeyDecoder.readingForPrefix(digits, candidate) + override fun nineKeyPathsFor(code: String?): List = nineKeyUiState.pathsFor(code) diff --git a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt index 4ba03819..761c468e 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt @@ -65,6 +65,9 @@ internal class NineKeyLocalDecoder( private val syllablePrefixes = HashSet() private val completeSyllables = HashSet() + /** Digit prefix of a syllable -> the letters of the likeliest syllable it starts, and that syllable's score. */ + private val prefixSpellings = HashMap>() + /** Han character -> syllables that can read it, most likely first. */ private val readingsByChar = HashMap>() private var previousDigits = "" @@ -81,7 +84,13 @@ internal class NineKeyLocalDecoder( } if (isSyllableEntry(entry)) { completeSyllables += entry.pinyin - for (length in 1..entry.digits.length) syllablePrefixes += entry.digits.substring(0, length) + val score = entryScore(entry) + for (length in 1..entry.digits.length) { + val prefix = entry.digits.substring(0, length) + syllablePrefixes += prefix + val known = prefixSpellings[prefix] + if (known == null || score > known.second) prefixSpellings[prefix] = entry.pinyin.substring(0, length) to score + } } } } @@ -186,15 +195,19 @@ internal class NineKeyLocalDecoder( ?.pinyin ?.take(bounded.length) ?.takeIf { digitsForPinyin(it) == bounded } + // The pre-edit is always letters: when no word or path decides them, + // spellDigits reads whole syllables and the start of an unfinished one + // (6442646 -> ni hao go). Showing the digits instead left "669" or a + // whole digit string in the pre-edit until (or after) Rime answered. val preview = when { stable != null -> stable continuous != null -> continuous - bounded.length == 1 -> bounded - paths.isEmpty() -> bounded + bounded.length == 1 -> spellDigits(bounded).joinToString(" ") + paths.isEmpty() -> prefixPreview ?: spellDigits(bounded).joinToString(" ") continuationBase != null && validPreset.isEmpty() && prefixPreview != null -> prefixPreview paths.isNotEmpty() -> paths.first() prefixPreview != null -> prefixPreview - else -> bounded + else -> spellDigits(bounded).joinToString(" ") } previousDigits = bounded @@ -271,6 +284,107 @@ internal class NineKeyLocalDecoder( return null } + /** + * The pre-edit for a candidate that spells only the start of the digits, + * the usual case in long input: its readings, the last one possibly cut + * where the digits end (你好工 for 6442646 -> ni hao go), then + * [spellDigits] for anything after the word. Null when the candidate does + * not fit the digits at all. + */ + @Synchronized + fun readingForPrefix(digits: String, text: String): List? { + val bounded = digits.filter { it in '2'..'9' } + if (bounded.isEmpty() || text.isEmpty()) return null + readingFor(bounded, text)?.let { return it } + val chars = ArrayList() + var index = 0 + while (index < text.length) { + val cp = text.codePointAt(index) + chars += String(Character.toChars(cp)) + index += Character.charCount(cp) + } + // Each character by its full reading, else by the start of one: Rime's + // nine-key matches initials (669 -> 模型 as mo'x) and its key + // corrections accept cut syllables (go for gong). Take the characters, + // in order, as far as they fit the digits; the rest is spelled. + data class Walk(val parts: List, val full: Int) + var states = mapOf(0 to Walk(emptyList(), 0)) + var bestEnd = 0 + var best = Walk(emptyList(), 0) + for (char in chars) { + val next = HashMap() + for ((digitIndex, walk) in states) { + if (digitIndex == bounded.length) continue + for (reading in readingsByChar[char].orEmpty()) { + val code = digitsForPinyin(reading) ?: continue + for (length in code.length downTo 1) { + if (digitIndex + length > bounded.length) continue + if (!bounded.startsWith(code.substring(0, length), digitIndex)) continue + val end = digitIndex + length + val step = Walk(walk.parts + reading.take(length), walk.full + if (length == code.length) 1 else 0) + val known = next[end] + if (known == null || step.full > known.full) next[end] = step + } + } + } + if (next.isEmpty()) break + next.forEach { (end, walk) -> + if (end > bestEnd || (end == bestEnd && walk.full > best.full)) { + bestEnd = end + best = walk + } + } + states = next + } + if (best.parts.isEmpty()) return null + return if (bestEnd == bounded.length) best.parts else best.parts + spellDigits(bounded.substring(bestEnd)) + } + + /** + * Letters for [digits], one per digit, when no word decides them: the + * fewest whole syllables, the likeliest start of one for an unfinished + * end, and a key's first letter only where nothing else reads. Never the + * digits themselves. + */ + @Synchronized + fun spellDigits(digits: String): List { + val bounded = digits.filter { it in '2'..'9' } + val n = bounded.length + if (n == 0) return emptyList() + // A part is a whole syllable, or the start of one: natural at the end + // (the syllable is still being typed), a last resort in the middle. Fewer + // and whole parts win (a lone a / o / e between syllables counts double, + // a grid artefact as in ni'ha'o); then likelier syllables and words. + data class Spelling(val parts: List, val cost: Int, val score: Int, val partialEnd: Boolean = false) + val better = compareBy { it.cost }.thenBy { it.partialEnd }.thenByDescending { it.score } + val states = Array(n + 1) { ArrayList() } + states[0] += Spelling(emptyList(), 0, 0) + for (start in 0 until n) { + val from = states[start].sortedWith(better).take(SPELL_BEAM) + if (from.isEmpty()) continue + var node = root + for (end in start until minOf(n, start + MAX_SYLLABLE_LENGTH)) { + node = node.children[bounded[end]] ?: break + val chunk = bounded.substring(start, end + 1) + val whole = node.exact.filter(::isSyllableEntry).sortedByDescending(::entryScore).take(SPELL_BRANCHES) + for (before in from) { + for (entry in whole) { + val parts = before.parts + entry.pinyin + val cost = if (entry.pinyin.length == 1 && n > 1) 2 else 1 + states[end + 1] += Spelling(parts, before.cost + cost, before.score + entryScore(entry) + phraseBonus(parts)) + } + val (letters, score) = prefixSpellings[chunk] ?: continue + if (whole.any { it.pinyin == letters }) continue + val last = end + 1 == n + states[end + 1] += Spelling(before.parts + letters, before.cost + if (last) 1 else 3, before.score + score / 2, partialEnd = last) + } + } + } + states[n].minWithOrNull(better)?.let { return it.parts } + // Unreachable while every key starts some syllable; keep the letters promise anyway. + return listOf(bounded.map { KEY_LETTERS.getValue(it).first() }.joinToString("")) + } + /** * The readings offered in the left rail, best first: one syllable per item, * for the next character only (`ni`, `mi`, ...). Fixing one moves the list on @@ -613,11 +727,19 @@ internal class NineKeyLocalDecoder( private const val MAX_SYLLABLE_LENGTH = 6 private const val MAX_PATHS = 12 private const val MAX_BEAM = 12 + private const val SPELL_BEAM = 8 + private const val SPELL_BRANCHES = 8 private const val MAX_BRANCHES = 10 private const val MAX_CANDIDATES = 96 private const val PER_PATH_CANDIDATES = 24 private const val PART_PENALTY = 55 + /** The letters printed on each nine-key digit. */ + private val KEY_LETTERS = mapOf( + '2' to "abc", '3' to "def", '4' to "ghi", '5' to "jkl", + '6' to "mno", '7' to "pqrs", '8' to "tuv", '9' to "wxyz", + ) + fun digitsForPinyin(pinyin: String): String? { if (pinyin.isBlank()) return null val digits = StringBuilder(pinyin.length) diff --git a/app/src/main/java/llc/slacker/openime/core/EmojiCatalog.kt b/app/src/main/java/llc/slacker/openime/core/EmojiCatalog.kt new file mode 100644 index 00000000..2d6b7b1c --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/EmojiCatalog.kt @@ -0,0 +1,58 @@ +package llc.slacker.openime.core + +import android.content.Context +import android.graphics.Paint + +/** + * The emoji panel's categories: [ImeData.emojiByCategory]'s curated order + * (its smileys have bundled Fluent images), then every other Unicode emoji + * of that category from assets/emoji/unicode_emoji.tsv (generated by + * scripts/generate_emoji_catalog.py). Emoji the phone's font cannot draw are + * left out, so an older phone shows no empty boxes for newer emoji. + */ +object EmojiCatalog { + private const val ASSET = "emoji/unicode_emoji.tsv" + + @Volatile + private var cached: LinkedHashMap>? = null + + fun byCategory(context: Context): LinkedHashMap> { + cached?.let { return it } + return synchronized(this) { + cached ?: build(context).also { cached = it } + } + } + + private fun build(context: Context): LinkedHashMap> { + val extra = LinkedHashMap>() + runCatching { + context.assets.open(ASSET).bufferedReader(Charsets.UTF_8).useLines { lines -> + lines.forEach { line -> + if (line.isBlank() || line.startsWith('#')) return@forEach + val tab = line.indexOf('\t') + if (tab > 0) extra.getOrPut(line.substring(0, tab)) { ArrayList() } += line.substring(tab + 1) + } + } + } + val paint = Paint() + return merge(ImeData.emojiByCategory, extra) { emoji -> paint.hasGlyph(emoji) } + } + + /** Curated first, then the catalog's additions; [drawable] filters both. */ + internal fun merge( + curated: Map>, + extra: Map>, + drawable: (String) -> Boolean, + ): LinkedHashMap> { + val result = LinkedHashMap>() + curated.forEach { (category, emoji) -> + // Compare without the variation selector: ❤ and ❤️ are one emoji. + val seen = HashSet() + val list = (emoji + extra[category].orEmpty()) + .filter { seen.add(it.replace("️", "")) } + .filter(drawable) + result[category] = list + } + return result + } +} diff --git a/app/src/main/java/llc/slacker/openime/core/SymbolCatalog.kt b/app/src/main/java/llc/slacker/openime/core/SymbolCatalog.kt new file mode 100644 index 00000000..dc34bd0a --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/SymbolCatalog.kt @@ -0,0 +1,90 @@ +package llc.slacker.openime.core + +/** + * The symbol panel's categories, in order, and what each lists. The older + * [ImeData.symbols] groups are kept as they were and extended here, plus + * whole new categories (拼音声调, 日文, 注音, 制表) that Chinese users reach + * for and had no home. + */ +object SymbolCatalog { + /** Panel order. "自定义" is the user's own list (CustomSymbolRepository). */ + val CATEGORIES: List = listOf( + "常用", "中文", "英文", "数学", "序号", "特殊", "网络颜文字", + "拼音", "日文", "注音", "制表", "单位", "编程", "自定义", + ) + + /** Full-width marks and signs beyond the basic punctuation. */ + val CHINESE_EXTRA: List = listOf( + "〈", "〉", "〔", "〕", "〖", "〗", "〘", "〙", "[", "]", "{", "}", + "〃", "々", "〆", "〇", "※", "§", "№", "‰", "℃", "°", "¥", "&", "@", + "#", "%", "*", "+", "-", "=", "/", "\", "|", "_", "﹏", "﹋", "﹌", + "︿", "﹀", "︵", "︶", "︷", "︸", "︹", "︺", "︻", "︼", "︽", "︾", + ) + + /** Stars, hearts, marks, music, weather, cards, gender and religion signs. */ + val SPECIAL_EXTRA: List = listOf( + "★", "☆", "✦", "✧", "✩", "✪", "✫", "✬", "✭", "✮", "✯", "✰", "✶", "✷", "✸", "✹", "✺", + "✻", "✼", "❄", "❅", "❆", "❇", "❈", "❉", "❊", "❋", "♥", "♡", "❤", "❥", "❦", "❧", + "✓", "✔", "✗", "✘", "☑", "☒", "☐", "♩", "♪", "♫", "♬", "♭", "♮", "♯", + "☀", "☁", "☂", "☃", "☄", "☾", "☽", "♠", "♣", "♦", "♤", "♧", "♢", + "♀", "♂", "⚥", "☯", "☮", "☸", "✿", "❀", "❁", "✾", "☎", "☏", "✆", "✉", "✎", "✏", "✐", + "⌘", "⌥", "⇧", "⏎", "⌫", "⌦", "♨", "☕", "⚠", "☢", "☣", "♻", "⚡", "☘", "⚓", "⚔", + "♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓", + ) + + /** Popular kaomoji. Long items get wide cells in the panel. */ + val KAOMOJI_EXTRA: List = listOf( + "( ̄▽ ̄)", "(^▽^)", "(≧▽≦)", "(*´▽`*)", "(。・ω・。)", "(・ω・)", "(`・ω・´)", "(´・ω・`)", + "( ´ ▽ ` )ノ", "ヾ(^∇^)", "ヽ(•‿•)ノ", "(☆▽☆)", "(✿◠‿◠)", "(◕‿◕)", "(◍•ᴗ•◍)", "(๑•̀ㅂ•́)و✧", + "(。♥‿♥。)", "(♡˙︶˙♡)", "(づ ̄ ³ ̄)づ", "(っ˘з(˘⌣˘ )", "(*/ω\*)", "(⁄ ⁄•⁄ω⁄•⁄ ⁄)", "(〃▽〃)", "(*^ω^*)", + "(╥﹏╥)", "(T_T)", "(ಥ﹏ಥ)", "(;´༎ຶД༎ຶ`)", "(っ °Д °;)っ", "Σ(っ°Д°;)っ", "(⊙_⊙)", "(°ロ°)!", + "(╯°□°)╯︵ ┻━┻", "┬─┬ノ( º _ ºノ)", "(ノ`Д´)ノ", "(╬ Ò﹏Ó)", "( ̄へ ̄)", "(¬_¬)", "(눈_눈)", "(`へ´)", + "¯\\_(ツ)_/¯", "┐( ̄ヘ ̄)┌", "(´-ω-`)", "( ̄ω ̄;)", "(-_-;)", "(^_^;)", "(ーー;)", "(* ̄m ̄)", + "( ̄o ̄) zzZZ", "(_ _)。゜zzZ", "(~﹃~)~zZ", "(>_<)", "(>﹏<)", "(。•́︿•̀。)", "(´;ω;`)", "(ノ_<。)", + "ψ(`∇´)ψ", "( ̄ε(# ̄)☆╰╮", "(ง •_•)ง", "ᕦ(ò_óˇ)ᕤ", "(•̀ᴗ•́)و", "(^^)b", "d( ̄▽ ̄)b", "(👍≖‿≖)👍", + "(=^・ω・^=)", "(=①ω①=)", "ʕ•ᴥ•ʔ", "U・ᴥ・U", "(・⊝・)", "(°∀°)ノ", "(*•̀ᴗ•́*)", "( ̄︶ ̄)", + "(o^^)o", "o(^^o)", "\(^o^)/", "ヽ(✿゚▽゚)ノ", "(^_−)☆", "(。•̀ᴗ-)✧", "( ̄ー ̄)b", "(•̀⌄•́)", + "( •̀ ω •́ )✧", "(◔◡◔)", "(˘•ω•˘)", "(。>﹏<。)", "(っ﹏-) .。o", "(*≧ω≦)", "( ˘•ω•˘ )", "(ノ◕ヮ◕)ノ*:・゚✧", + ) + + /** Pinyin with tone marks (and ü, ê, ḿ/ń for the rarer syllables). */ + val PINYIN_TONES: List = listOf( + "ā", "á", "ǎ", "à", "ō", "ó", "ǒ", "ò", "ē", "é", "ě", "è", "ī", "í", "ǐ", "ì", + "ū", "ú", "ǔ", "ù", "ǖ", "ǘ", "ǚ", "ǜ", "ü", "ê", "ê̄", "ế", "ê̌", "ề", "ḿ", "ń", "ň", "ǹ", + "Ā", "Á", "Ǎ", "À", "Ō", "Ó", "Ǒ", "Ò", "Ē", "É", "Ě", "È", "Ī", "Í", "Ǐ", "Ì", + "Ū", "Ú", "Ǔ", "Ù", "Ǖ", "Ǘ", "Ǚ", "Ǜ", "Ü", "ˉ", "ˊ", "ˇ", "ˋ", "˙", + ) + + /** Hiragana, then katakana, gojūon order with small kana and marks. */ + val JAPANESE: List = listOf( + "あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ", "さ", "し", "す", "せ", "そ", + "た", "ち", "つ", "て", "と", "な", "に", "ぬ", "ね", "の", "は", "ひ", "ふ", "へ", "ほ", + "ま", "み", "む", "め", "も", "や", "ゆ", "よ", "ら", "り", "る", "れ", "ろ", "わ", "を", "ん", + "が", "ぎ", "ぐ", "げ", "ご", "ざ", "じ", "ず", "ぜ", "ぞ", "だ", "ぢ", "づ", "で", "ど", + "ば", "び", "ぶ", "べ", "ぼ", "ぱ", "ぴ", "ぷ", "ぺ", "ぽ", "ぁ", "ぃ", "ぅ", "ぇ", "ぉ", "ゃ", "ゅ", "ょ", "っ", + "ア", "イ", "ウ", "エ", "オ", "カ", "キ", "ク", "ケ", "コ", "サ", "シ", "ス", "セ", "ソ", + "タ", "チ", "ツ", "テ", "ト", "ナ", "ニ", "ヌ", "ネ", "ノ", "ハ", "ヒ", "フ", "ヘ", "ホ", + "マ", "ミ", "ム", "メ", "モ", "ヤ", "ユ", "ヨ", "ラ", "リ", "ル", "レ", "ロ", "ワ", "ヲ", "ン", + "ガ", "ギ", "グ", "ゲ", "ゴ", "ザ", "ジ", "ズ", "ゼ", "ゾ", "ダ", "ヂ", "ヅ", "デ", "ド", + "バ", "ビ", "ブ", "ベ", "ボ", "パ", "ピ", "プ", "ペ", "ポ", "ァ", "ィ", "ゥ", "ェ", "ォ", "ャ", "ュ", "ョ", "ッ", + "ヴ", "ー", "・", "゛", "゜", "ゝ", "ゞ", "ヽ", "ヾ", + ) + + /** Zhuyin (bopomofo) with its tone marks. */ + val ZHUYIN: List = listOf( + "ㄅ", "ㄆ", "ㄇ", "ㄈ", "ㄉ", "ㄊ", "ㄋ", "ㄌ", "ㄍ", "ㄎ", "ㄏ", "ㄐ", "ㄑ", "ㄒ", + "ㄓ", "ㄔ", "ㄕ", "ㄖ", "ㄗ", "ㄘ", "ㄙ", "ㄚ", "ㄛ", "ㄜ", "ㄝ", "ㄞ", "ㄟ", "ㄠ", "ㄡ", + "ㄢ", "ㄣ", "ㄤ", "ㄥ", "ㄦ", "ㄧ", "ㄨ", "ㄩ", "ˉ", "ˊ", "ˇ", "ˋ", "˙", + ) + + /** Box drawing and block elements, for tables and bars in plain text. */ + val BOX_DRAWING: List = listOf( + "─", "━", "│", "┃", "┄", "┅", "┆", "┇", "┈", "┉", "┊", "┋", + "┌", "┍", "┎", "┏", "┐", "┑", "┒", "┓", "└", "┕", "┖", "┗", "┘", "┙", "┚", "┛", + "├", "┝", "┠", "┣", "┤", "┥", "┨", "┫", "┬", "┯", "┰", "┳", "┴", "┷", "┸", "┻", "┼", "┿", "╂", "╋", + "═", "║", "╒", "╓", "╔", "╕", "╖", "╗", "╘", "╙", "╚", "╛", "╜", "╝", + "╞", "╟", "╠", "╡", "╢", "╣", "╤", "╥", "╦", "╧", "╨", "╩", "╪", "╫", "╬", + "╭", "╮", "╯", "╰", "╱", "╲", "╳", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", + "▉", "▊", "▋", "▌", "▍", "▎", "▏", "░", "▒", "▓", "▔", "▕", + ) +} diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt index 914281eb..7f1dd10c 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -30,6 +30,7 @@ import llc.slacker.openime.candidate.NineKeyLocalDecoder import llc.slacker.openime.candidate.NineKeyPerformanceTrace import llc.slacker.openime.candidate.NineKeyReading import llc.slacker.openime.candidate.StrokeLexicon +import llc.slacker.openime.core.FuzzyRule import llc.slacker.openime.core.ImeState import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel @@ -2277,7 +2278,10 @@ open class ImeKeyboardView( tag = "panel-section-title" } - private fun onState(seed: String): Boolean = when (seed) { + private fun onState(seed: String): Boolean = FuzzyRule.fromLabel(seed) + // A 模糊音 pair: its own saved state (the theme paints the track from this). + ?.let { it in ImeSettingsRepository.loadFuzzyRules(context) } + ?: when (seed) { "按键音效" -> soundEnabled "触感震动" -> hapticEnabled "模糊音纠错", "启用模糊音" -> fuzzyEnabled @@ -2288,7 +2292,7 @@ open class ImeKeyboardView( "语音去语气词" -> ImeSettingsRepository.loadVoiceStripFillers(context) "标点用空格代替" -> ImeSettingsRepository.loadVoicePunctuationAsSpace(context) else -> true - } + } private fun updateSettingToggle(seed: String, enabled: Boolean) { when (seed) { @@ -2579,7 +2583,7 @@ open class ImeKeyboardView( topCandidate.offsetByCodePoints(0, fixedSyllables) } val syllables = candidateProvider - ?.nineKeyReadingFor(digits, topCandidate.substring(skipped)) + ?.nineKeyPreviewFor(digits, topCandidate.substring(skipped)) ?.takeIf { it.isNotEmpty() } ?: return val aligned = prefix + syllables.joinToString("'") @@ -2656,12 +2660,10 @@ open class ImeKeyboardView( syllables.forEach { spelled.append(NineKeyLocalDecoder.digitsForPinyin(it) ?: return) } if (digits.isEmpty() || !digits.startsWith(spelled)) return - if (!reading.complete) { - // A bare initial is orientation, not a decision: show it, fix nothing. - lastNineSegmentPrefix = prefix - publishNineKeyDigits(digits, preferredSuffix = syllables.single()) - return - } + // A bare initial (m, w, x ...) is a choice like any syllable: tapping it + // fixes that letter, so the candidates start with it (m -> 么 吗 们). + // Treated as a mere hint it changed nothing visible: Rime still read the + // digit and the pre-edit snapped back to its top candidate (6 -> o). val rest = digits.substring(spelled.length) if (rest.isEmpty()) { val last = syllables.last() diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt index a860a561..3f471bcc 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -227,8 +227,12 @@ internal class ImeThemeApplier( } private fun applyScrollView(view: ScrollView, t: ImeTheme.Tokens) { - if (view.tag == "nine-punct-stack" && view.getChildAt(0)?.tag == "nine-pinyin-panel") { - // Reading list: the whole rail is one panel and its items are flat. + val content = view.getChildAt(0)?.tag + if ( + (view.tag == "nine-punct-stack" || view.tag == "digits-symbol-scroll") && + content in setOf("nine-pinyin-panel", "nine-symbol-scroll-content", "digits-symbol-scroll-content") + ) { + // Reading or symbol list: the whole rail is one panel and its items are flat. // Inset by half a key gap so the panel lines up with the key grid. view.background = InsetDrawable( ImeDrawableFactory.rounded(t.sideKeyBackground, toPx(ImeGeometryTokens.KEY_RADIUS_DP)), @@ -244,6 +248,8 @@ internal class ImeThemeApplier( view.tag == "candidate-emoji" || view.tag == "candidate-expand" -> 20 view.tag == "key-panel-back" -> 24 view.tag?.toString()?.startsWith("clip-pin:") == true || view.tag?.toString()?.startsWith("phrase-") == true -> 18 + // Tile icons (工具 and 切换键盘 share the tile): one size for both pages. + view.tag == "tool-icon" || view.tag == "tool-icon-selected" -> 28 hasAncestorTag(view, "tools-panel") -> 24 (view.parent as? View)?.tag == "toolbar-row" -> 22 else -> 0 @@ -269,6 +275,15 @@ internal class ImeThemeApplier( } when { + view.tag == "punct:add" || view.tag == "digit-symbol:add" -> { + // The rail's circled plus: drawn like the symbols around it. + view.imageTintList = ColorStateList.valueOf(t.sideKeyText) + view.background = statefulRounded( + Color.TRANSPARENT, + ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), + toPx(ImeGeometryTokens.KEY_RADIUS_DP), + ) + } (view.tag as? String)?.startsWith("clip-pin:") == true || (view.tag as? String)?.startsWith("phrase-") == true -> { view.imageTintList = ColorStateList.valueOf(if (view.isSelected) t.primary else t.keySecondaryText) } @@ -501,9 +516,10 @@ internal class ImeThemeApplier( } tag?.startsWith("punct:") == true || tag?.startsWith("digit-symbol:") == true -> { + // Flat on the rail's panel; only a press shows a surface. view.setTextColor(t.keyText) view.background = statefulRounded( - t.sideKeyBackground, + Color.TRANSPARENT, ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) @@ -609,12 +625,16 @@ internal class ImeThemeApplier( ?.substringBefore(',') .orEmpty() val enabled = toggleState(seed) + // padsView = false: the track must not replace the switch's own + // 2dp side padding, or the knob sits 0 from the left edge when + // off and 4 from the right when on. view.background = paintedWithinTarget( ImeDrawableFactory.rounded( ImeSurfacePolicy.switchTrack(enabled, t), toPx(ImeGeometryTokens.PILL_RADIUS_DP), ), ImeGeometryTokens.SWITCH_HEIGHT_DP, + padsView = false, ) } } @@ -622,6 +642,9 @@ internal class ImeThemeApplier( private fun applyTaggedView(view: View, t: ImeTheme.Tokens) { when (view.tag) { + SymbolRailRenderer.DIVIDER_TAG -> view.setBackgroundColor( + ImeDrawableFactory.blend(t.keySecondaryText, t.sideKeyBackground, 0.22f), + ) "handwriting-canvas" -> { view.background = ImeDrawableFactory.rounded( t.canvasBackground, diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt index a2d2fd18..e52c54a5 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt @@ -73,7 +73,7 @@ internal class NineKeySymbolRailController( contentTag = NINE_CONTENT_TAG, contentDescription = "九键拼音筛选或常用符号,上下滑动查看更多", symbols = commonSymbols(), - cellHeightDp = cellHeightDp(), + cellHeightDp = symbolCellHeightDp(), toPx = toPx, tagPrefix = "punct:", onCommit = onCommit, @@ -161,7 +161,7 @@ internal class NineKeySymbolRailController( SymbolRailRenderer.populate( scroll = scroll, symbols = commonSymbols(), - cellHeightDp = cellHeightDp(), + cellHeightDp = symbolCellHeightDp(), toPx = toPx, tagPrefix = "punct:", onCommit = onCommit, @@ -266,6 +266,9 @@ internal class NineKeySymbolRailController( private fun speakablePath(path: String): String = path.replace(" ", "、").replace("|", "、").replace("'", "、") + /** Three symbols to the rail's three key rows, level with the keys beside them. */ + private fun symbolCellHeightDp(): Int = cellHeightDp() + /** The user's rail (ten common marks until edited), then + to edit it. */ private fun commonSymbols(): List = RailSymbolRepository.load(context) diff --git a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt index c01beed5..686f8fab 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt @@ -116,23 +116,33 @@ internal class Pinyin9KeyboardRenderer( orientation = LinearLayout.VERTICAL tag = "pinyin9-actions" } + // 删除 · 重输 · 0 · 确定, one key row each: 0 has its own key, as on a + // phone keypad, instead of only a swipe-up on 1. side.addView( createBackspaceKey().apply { markSideKey(this) }, - sideKeyParams(nineBodyHeightDp() / 3), + sideKeyParams(nineBodyHeightDp() / 4), ) side.addView( createKey("重输", true, null, ImeTypographyTokens.BODY_SP, onRetranslate).apply { tag = "key-retype" markSideKey(this) }, - sideKeyParams(nineBodyHeightDp() / 3), + sideKeyParams(nineBodyHeightDp() / 4), + ) + side.addView( + createKey("0", true, null, ImeTypographyTokens.KEY_LETTER_SP) { onCommitCharacter("0") }.apply { + tag = "key-9:0" + contentDescription = "0" + markSideKey(this) + }, + sideKeyParams(nineBodyHeightDp() / 4), ) side.addView( createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, onEnter).apply { tag = "key-enter" markSideKey(this) }, - sideKeyParams(nineBodyHeightDp() / 3), + sideKeyParams(nineBodyHeightDp() / 4), ) container.addView(side, adaptiveColumnParams(1f)) diff --git a/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt index 1c8227e6..abf4bacd 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt @@ -6,9 +6,11 @@ import android.graphics.Rect import android.view.Gravity import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView +import llc.slacker.openime.R import llc.slacker.openime.theme.ImeGeometryTokens /** @@ -18,6 +20,7 @@ import llc.slacker.openime.theme.ImeGeometryTokens */ internal object SymbolRailRenderer { private const val CELL_HEIGHT_DP = 54 + private const val FADING_EDGE_DP = 18 fun build( context: Context, @@ -39,6 +42,9 @@ internal object SymbolRailRenderer { val scroll = ScrollView(context).apply { tag = railTag isVerticalScrollBarEnabled = false + // The list fades out at its edges: more lies beyond. + isVerticalFadingEdgeEnabled = true + setFadingEdgeLength(toPx(FADING_EDGE_DP)) overScrollMode = View.OVER_SCROLL_NEVER clipToPadding = false this.contentDescription = contentDescription @@ -90,7 +96,23 @@ internal object SymbolRailRenderer { cellParams(content.context, withGap = true, heightDp = cellHeightDp, toPx = toPx), ) } + // One flat list on one panel (the theme draws the panel), hairlines + // between the items and faded edges: it reads as something to scroll, + // not a column of separate keys. Three items show, level with the keys. + fun divider() { + content.addView( + View(content.context).apply { + tag = DIVIDER_TAG + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1).apply { + marginStart = toPx(DIVIDER_INSET_DP) + marginEnd = toPx(DIVIDER_INSET_DP) + }, + ) + } symbols.forEachIndexed { index, symbol -> + if (index > 0) divider() content.addView( symbolCell( context = content.context, @@ -100,34 +122,34 @@ internal object SymbolRailRenderer { onCommit = onCommit, onFeedback = onFeedback, ), - cellParams( - content.context, - withGap = index < symbols.lastIndex || onAdd != null, - heightDp = cellHeightDp, - toPx = toPx, - ), + flatCellParams(heightDp = cellHeightDp, toPx = toPx), ) } if (onAdd != null) { + if (symbols.isNotEmpty()) divider() content.addView( - symbolCell( - context = content.context, - symbol = ADD_LABEL, - inheritedTextColor = inheritedTextColor, - tagPrefix = tagPrefix, - onCommit = { onAdd() }, - onFeedback = onFeedback, - ).apply { + ImageView(content.context).apply { + setImageResource(R.drawable.ic_rail_add) + scaleType = ImageView.ScaleType.CENTER tag = "${tagPrefix}add" contentDescription = "添加和排序常用符号" + isClickable = true + isFocusable = true + setOnClickListener { + onFeedback() + onAdd() + } }, - cellParams(content.context, withGap = false, heightDp = cellHeightDp, toPx = toPx), + flatCellParams(heightDp = cellHeightDp, toPx = toPx), ) } } - /** The rail editor's cell: a plain plus, drawn like the symbols around it. */ - private const val ADD_LABEL = "+" + const val DIVIDER_TAG = "rail-divider" + private const val DIVIDER_INSET_DP = 10 + + private fun flatCellParams(heightDp: Int, toPx: (Int) -> Int) = + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(heightDp)) fun cellParams(context: Context, withGap: Boolean, heightDp: Int = CELL_HEIGHT_DP, toPx: (Int) -> Int = { dp(context, it) }) = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, diff --git a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt index 8e1c427e..100d7156 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -18,7 +18,9 @@ import android.widget.ScrollView import android.widget.TextView import llc.slacker.openime.R import llc.slacker.openime.SymbolManagerActivity +import llc.slacker.openime.core.EmojiCatalog import llc.slacker.openime.core.ImeData +import llc.slacker.openime.core.SymbolCatalog import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel import llc.slacker.openime.data.CustomSymbolRepository @@ -429,9 +431,7 @@ internal class ImePanelRenderer( notifyRebuilt: Boolean, ) { body.removeAllViews() - val categories = listOf( - "常用", "中文", "英文", "数学", "序号", "特殊", "网络颜文字", "单位", "编程", "自定义", - ) + val categories = SymbolCatalog.CATEGORIES body.orientation = LinearLayout.HORIZONTAL body.setPadding(toPx(8), toPx(5), toPx(4), 0) val categoryColumn = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } @@ -482,20 +482,25 @@ internal class ImePanelRenderer( ), ) } - items.chunked(5).forEach { chunk -> + // Kaomoji and other long items get three wide cells a row and a smaller + // size; five narrow cells cut (づ。◕‿‿◕。)づ down to "(づ。". + val wide = items.any { it.codePointCount(0, it.length) > WIDE_SYMBOL_CODE_POINTS } + val columns = if (wide) 3 else 5 + val textSize = if (wide) ImeTypographyTokens.BODY_SP else ImeTypographyTokens.KEY_LETTER_SP + items.chunked(columns).forEach { chunk -> val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } chunk.forEach { symbol -> row.addView( createKey( symbol, false, - ImeTypographyTokens.KEY_LETTER_SP, + textSize, ) { onSymbolSelected(symbol) }, - gridCellParams(54, 5, 0), + gridCellParams(54, columns, 0), ) } - repeat(5 - chunk.size) { - row.addView(View(context), gridCellParams(54, 5, 0)) + repeat(columns - chunk.size) { + row.addView(View(context), gridCellParams(54, columns, 0)) } grid.addView( row, @@ -525,7 +530,7 @@ internal class ImePanelRenderer( notifyRebuilt: Boolean, ) { body.removeAllViews() - val categories = listOf("全部") + ImeData.emojiByCategory.keys.toList() + val categories = listOf("全部") + EmojiCatalog.byCategory(context).keys.toList() fun displayCategory(category: String): String = when (category) { "人物/手势" -> "手势" "动物/自然" -> "动物" @@ -552,9 +557,9 @@ internal class ImePanelRenderer( setPadding(toPx(4), 0, toPx(4), 0) } val items = if (emojiCategory == "全部") { - (EmojiRecentRepository.load(context) + ImeData.emojiByCategory.values.flatten()).distinct() + (EmojiRecentRepository.load(context) + EmojiCatalog.byCategory(context).values.flatten()).distinct() } else { - val catalog = ImeData.emojiByCategory[emojiCategory].orEmpty() + val catalog = EmojiCatalog.byCategory(context)[emojiCategory].orEmpty() if (emojiCategory == "笑脸") (ImeData.referenceSmileys + catalog).distinct() else catalog } if (items.isEmpty() && emojiCategory == "最近") { @@ -707,7 +712,10 @@ internal class ImePanelRenderer( } private fun symbolItems(category: String): List = when (category) { - "中文" -> listOf(",", "。", "、", ";", ":", "?", "!", "…", "—", "~", "·", "「", "」", "『", "』", "(", ")", "《", "》", "【", "】", "“", "”", "‘", "’") + "中文" -> ( + listOf(",", "。", "、", ";", ":", "?", "!", "…", "—", "~", "·", "「", "」", "『", "』", "(", ")", "《", "》", "【", "】", "“", "”", "‘", "’") + + SymbolCatalog.CHINESE_EXTRA + ).distinct() "英文" -> ImeData.symbols["英文标点"].orEmpty() "数学" -> listOf( ImeData.symbols["数学运算"].orEmpty(), @@ -726,11 +734,16 @@ internal class ImePanelRenderer( "编程" -> ImeData.symbols["技术编程"].orEmpty() "特殊" -> listOf( ImeData.symbols["特殊图形"].orEmpty(), + SymbolCatalog.SPECIAL_EXTRA, ImeData.symbols["几何图形"].orEmpty(), ImeData.symbols["箭头线条"].orEmpty(), ImeData.symbols["括号边框"].orEmpty(), - ).flatten() - "网络颜文字" -> ImeData.symbols["网络颜文字"].orEmpty() + ).flatten().distinct() + "网络颜文字" -> (ImeData.symbols["网络颜文字"].orEmpty() + SymbolCatalog.KAOMOJI_EXTRA).distinct() + "拼音" -> SymbolCatalog.PINYIN_TONES + "日文" -> SymbolCatalog.JAPANESE + "注音" -> SymbolCatalog.ZHUYIN + "制表" -> SymbolCatalog.BOX_DRAWING "自定义" -> CustomSymbolRepository.load(context).map { it.symbol } else -> listOf( ImeData.symbols["常用"].orEmpty(), @@ -755,5 +768,7 @@ internal class ImePanelRenderer( private companion object { const val TOOL_COLUMNS = 4 + /** Longer than this, a symbol is text (kaomoji, 【】 pairs...) and needs a wide cell. */ + const val WIDE_SYMBOL_CODE_POINTS = 3 } } diff --git a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt index 3e9e1f34..8be5e219 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -7,6 +7,7 @@ import android.content.Context import android.os.Build import android.text.TextUtils import android.view.Gravity +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.animation.DecelerateInterpolator @@ -185,10 +186,10 @@ internal class SettingsPanelController( content.addSection("按键与输入") content.addCard( toggleRow("按键音效", "按键时播放提示音", R.drawable.ic_pref_sound), - soundStyleRow(R.drawable.ic_pref_sound), + soundStyleRow(NESTED_ROW), toggleRow("触感震动", "清脆短促,按下即停", R.drawable.ic_pref_haptic), - hapticStyleRow(R.drawable.ic_pref_haptic), - sliderRow("震动强度", R.drawable.ic_pref_haptic, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged), + hapticStyleRow(NESTED_ROW), + sliderRow("震动强度", NESTED_ROW, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged), toggleRow("按键气泡", "按下时显示字母预览", R.drawable.ic_pref_bubble), toggleRow("数字和符号提示", "字母键右上角显示数字和符号", R.drawable.ic_pref_hints), toggleRow("上滑输入数字", "按键上滑输入右上角的数字或符号", R.drawable.ic_pref_swipe_up), @@ -394,7 +395,7 @@ internal class SettingsPanelController( importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(1).coerceAtLeast(1)).apply { - marginStart = toPx(if (standalone) 60 else 14) + marginStart = textStart(rows[index - 1]) }, ) } @@ -411,21 +412,35 @@ internal class SettingsPanelController( ) } + /** Where [row]'s text starts: its padding, plus the icon tile when it has one. */ + private fun textStart(row: View): Int { + val group = row as? ViewGroup ?: return 0 + val icon = (0 until group.childCount).map { group.getChildAt(it) }.firstOrNull { it.tag == "setting-icon" } + val iconSpan = icon?.let { it.layoutParams.width + ((it.layoutParams as? ViewGroup.MarginLayoutParams)?.marginEnd ?: 0) } ?: 0 + return group.paddingLeft + iconSpan + } + // ---- Rows --------------------------------------------------------------- - /** Row shell: an optional icon tile, then [body] filling the rest. */ - private fun rowShell(iconRes: Int, alignTop: Boolean = false): LinearLayout = + /** + * Row shell: an optional icon tile, then the body filling the rest. The tile + * is centred on the whole row, whatever sits under the label. [NESTED_ROW] + * leaves the tile out and starts the body on the text column, for settings + * that refine the row above (音效 under 按键音效). + */ + private fun rowShell(iconRes: Int): LinearLayout = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL - gravity = if (alignTop) Gravity.TOP else Gravity.CENTER_VERTICAL + gravity = Gravity.CENTER_VERTICAL tag = "setting-row" minimumHeight = toPx(if (standalone) ImeGeometryTokens.SETTING_ROW_HEIGHT_DP else 52) + val nestedInset = if (iconRes == NESTED_ROW) toPx(ICON_TILE_DP + ICON_TILE_GAP_DP) else 0 if (standalone) { - setPadding(toPx(16), toPx(12), toPx(16), toPx(12)) + setPadding(toPx(16) + nestedInset, toPx(12), toPx(16), toPx(12)) } else { setPadding(toPx(14), toPx(2), toPx(12), toPx(2)) } - if (iconRes != 0) { + if (iconRes != 0 && iconRes != NESTED_ROW) { addView( ImageView(context).apply { setImageResource(iconRes) @@ -434,7 +449,7 @@ internal class SettingsPanelController( importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO tag = "setting-icon" }, - LinearLayout.LayoutParams(toPx(32), toPx(32)).apply { marginEnd = toPx(12) }, + LinearLayout.LayoutParams(toPx(ICON_TILE_DP), toPx(ICON_TILE_DP)).apply { marginEnd = toPx(ICON_TILE_GAP_DP) }, ) } } @@ -524,7 +539,10 @@ internal class SettingsPanelController( importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO tag = "setting-chevron" }, - LinearLayout.LayoutParams(toPx(18), toPx(18)).apply { marginStart = toPx(ImeSpacingTokens.MD_DP) }, + LinearLayout.LayoutParams(toPx(ImeGeometryTokens.CHEVRON_DP), toPx(ImeGeometryTokens.CHEVRON_DP)).apply { + marginStart = toPx(ImeSpacingTokens.MD_DP) + marginEnd = -toPx(ImeGeometryTokens.CHEVRON_GLYPH_END_INSET_DP) + }, ) } @@ -588,11 +606,11 @@ internal class SettingsPanelController( overScrollMode = View.OVER_SCROLL_NEVER addView(chips) } - val row = rowShell(iconRes, alignTop = true) + val row = rowShell(iconRes) row.addView( LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - addView(rowLabel(label), wrapParams().apply { topMargin = toPx(if (iconRes != 0) 6 else 8) }) + addView(rowLabel(label), wrapParams().apply { topMargin = toPx(if (standalone) 0 else 8) }) addView( scroller, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(ImeGeometryTokens.TOUCH_TARGET_DP)).apply { @@ -647,12 +665,16 @@ internal class SettingsPanelController( ) } } - val row = rowShell(iconRes, alignTop = standalone) + val row = rowShell(iconRes) if (standalone) { + // The track lines up with the label and the value; the thumb's outer + // half overhangs the text column at 0% and 100%. + row.clipChildren = false + row.clipToPadding = false row.addView( LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - addView(rowLabel(label), wrapParams().apply { topMargin = toPx(6) }) + addView(rowLabel(label), wrapParams().apply { topMargin = toPx(3) }) addView( track, LinearLayout.LayoutParams( @@ -741,8 +763,12 @@ internal class SettingsPanelController( seekBar.contentDescription = initialDescription if (Build.VERSION.SDK_INT >= 30) seekBar.stateDescription = initialDescription - val row = rowShell(iconRes, alignTop = standalone) + val row = rowShell(iconRes) if (standalone) { + // The track lines up with the label and the value; the thumb's outer + // half overhangs the text column at 0% and 100%. + row.clipChildren = false + row.clipToPadding = false row.addView( LinearLayout(context).apply { orientation = LinearLayout.VERTICAL @@ -753,18 +779,21 @@ internal class SettingsPanelController( addView(rowLabel(label), LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) addView(valueView, wrapParams()) }, - LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { - topMargin = toPx(6) - }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT), ) addView( seekBar, - LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(40)), + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(40)).apply { + marginStart = -thumbInset + marginEnd = -thumbInset + // The bar's empty lower half hangs into the row padding, so + // the padding stays even and the icon sits on the row's centre. + bottomMargin = -toPx(6) + }, ) }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f), ) - row.setPadding(row.paddingLeft, row.paddingTop, row.paddingRight, toPx(4)) } else { row.addView(rowLabel(shortLabel), LinearLayout.LayoutParams(toPx(64), LinearLayout.LayoutParams.WRAP_CONTENT)) row.addView(seekBar, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f)) @@ -854,10 +883,8 @@ internal class SettingsPanelController( gravity = Gravity.START or Gravity.CENTER_VERTICAL } tag = "toggle-knob" - elevation = toPx(1).toFloat() - translationX = - if (isOn) toPx(ImeGeometryTokens.SWITCH_KNOB_TRAVEL_DP).toFloat() - else 0f + // A soft lift, as on iOS: the knob reads as a part that slides. + elevation = toPx(2).toFloat() } return FrameLayout(context).apply { @@ -883,6 +910,26 @@ internal class SettingsPanelController( updateAccessibilityState(isOn) addView(knob) + // The knob's travel from the laid-out pixels, not a dp constant: + // rounded separately, the constants left the two ends a pixel apart. + fun travel(): Float = (width - paddingLeft - paddingRight - knob.width).coerceAtLeast(0).toFloat() + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (knob.animation == null && !knob.isPressed) { + knob.translationX = if (toggleState(seed)) travel() else 0f + } + } + // Pressed, the knob stretches a little toward where it will go. + setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + knob.pivotX = if (toggleState(seed)) knob.width.toFloat() else 0f + knob.animate().scaleX(KNOB_PRESS_STRETCH).setDuration(ImeMotionTokens.STANDARD_TRANSITION_MS).start() + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + knob.animate().scaleX(1f).setDuration(ImeMotionTokens.STANDARD_TRANSITION_MS).start() + } + false + } setOnClickListener { onFeedback() val next = !toggleState(seed) @@ -890,12 +937,10 @@ internal class SettingsPanelController( updateAccessibilityState(next) onChanged(next) - val knobView = getChildAt(0) - knobView.animate().cancel() - knobView.animate() - .translationX( - if (next) toPx(ImeGeometryTokens.SWITCH_KNOB_TRAVEL_DP).toFloat() else 0f, - ) + knob.animate().cancel() + knob.animate() + .translationX(if (next) travel() else 0f) + .scaleX(1f) .setDuration(ImeMotionTokens.STANDARD_TRANSITION_MS) .setInterpolator(DecelerateInterpolator(1.5f)) .start() @@ -960,6 +1005,13 @@ internal class SettingsPanelController( ) private companion object { + /** rowShell icon value for a row that refines the one above: no tile, body on the text column. */ + const val NESTED_ROW = -1 + const val ICON_TILE_DP = 32 + const val ICON_TILE_GAP_DP = 12 + /** How much a pressed switch knob widens (iOS-like). */ + const val KNOB_PRESS_STRETCH = 1.14f + /** A character pair each 模糊音 switch makes interchangeable. */ val FUZZY_EXAMPLES: Map = mapOf( FuzzyRule.Z_ZH to "资 zi · 知 zhi", diff --git a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt index 5b05bdd1..65cd1846 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -29,11 +29,17 @@ internal object ImeGeometryTokens { const val SETTING_ROW_HEIGHT_DP = 56 const val TOOL_CARD_HEIGHT_DP = 80 const val VOICE_CONTROL_HEIGHT_DP = TOUCH_TARGET_DP - const val SWITCH_WIDTH_DP = 46 - const val SWITCH_HEIGHT_DP = 28 - const val SWITCH_KNOB_DP = 24 + // iOS switch geometry: a 51x31 track and a 27 knob, so the knob sits 2 + // from every edge, on and off alike. + const val SWITCH_WIDTH_DP = 51 + const val SWITCH_HEIGHT_DP = 31 + const val SWITCH_KNOB_DP = 27 const val SWITCH_PADDING_DP = 2 - const val SWITCH_KNOB_TRAVEL_DP = SWITCH_WIDTH_DP - SWITCH_PADDING_DP * 2 - SWITCH_KNOB_DP + // ic_chevron_right at 18dp: the stroke ends 6dp short of the box's right + // edge, so rows pull the box out by that much and the glyph ends on the + // same 16dp line as switches and values. + const val CHEVRON_DP = 18 + const val CHEVRON_GLYPH_END_INSET_DP = 6 const val KEY_GAP_DP = 6 const val KEY_ROW_GAP_DP = KEY_GAP_DP const val KEY_SIDE_MARGIN_DP = 2 @@ -47,7 +53,8 @@ internal object ImeGeometryTokens { const val TOOLBAR_HEIGHT_DP = TOUCH_TARGET_DP const val TOP_BAR_HEIGHT_DP = 48 const val ICON_SIZE_DP = 24 - const val STEP_MARK_SIZE_DP = 28 + // Same as a settings icon tile, so step text lines up with the rows below. + const val STEP_MARK_SIZE_DP = 32 const val HERO_MARK_SIZE_DP = 72 const val COMPOSED_TOP_ZONE_HEIGHT_DP = 70 } diff --git a/app/src/main/res/drawable/ic_brand_mark.xml b/app/src/main/res/drawable/ic_brand_mark.xml index 6046cecb..45dad94c 100644 --- a/app/src/main/res/drawable/ic_brand_mark.xml +++ b/app/src/main/res/drawable/ic_brand_mark.xml @@ -1,4 +1,11 @@ - - - - \ No newline at end of file + + + + + + + diff --git a/app/src/main/res/drawable/ic_kb_digits.xml b/app/src/main/res/drawable/ic_kb_digits.xml index b4633a04..a0a5681b 100644 --- a/app/src/main/res/drawable/ic_kb_digits.xml +++ b/app/src/main/res/drawable/ic_kb_digits.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_kb_english.xml b/app/src/main/res/drawable/ic_kb_english.xml index 2f42c176..7e928f90 100644 --- a/app/src/main/res/drawable/ic_kb_english.xml +++ b/app/src/main/res/drawable/ic_kb_english.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_kb_pinyin26.xml b/app/src/main/res/drawable/ic_kb_pinyin26.xml index 9c06f78a..ad275b51 100644 --- a/app/src/main/res/drawable/ic_kb_pinyin26.xml +++ b/app/src/main/res/drawable/ic_kb_pinyin26.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_kb_pinyin9.xml b/app/src/main/res/drawable/ic_kb_pinyin9.xml index 04717d82..84c038b7 100644 --- a/app/src/main/res/drawable/ic_kb_pinyin9.xml +++ b/app/src/main/res/drawable/ic_kb_pinyin9.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_kb_stroke.xml b/app/src/main/res/drawable/ic_kb_stroke.xml index 76e71d5f..0128c800 100644 --- a/app/src/main/res/drawable/ic_kb_stroke.xml +++ b/app/src/main/res/drawable/ic_kb_stroke.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index fc620fcf..60f45dcd 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,10 +1,18 @@ + + - + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..b6ebe96c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_rail_add.xml b/app/src/main/res/drawable/ic_rail_add.xml new file mode 100644 index 00000000..e2d3b115 --- /dev/null +++ b/app/src/main/res/drawable/ic_rail_add.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1c746bd5..f94247c1 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -22,7 +22,7 @@ - + @@ -35,23 +35,23 @@ - + - + - + - + - + @@ -62,7 +62,7 @@ - + @@ -78,8 +78,8 @@ - - + + @@ -99,16 +99,16 @@ - + - + - + - + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml index 5c84730c..c78bee3b 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -2,5 +2,5 @@ - + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml index 5c84730c..c78bee3b 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -2,5 +2,5 @@ - + diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml new file mode 100644 index 00000000..43a12a94 --- /dev/null +++ b/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index eb83874c..83124e66 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -28,7 +28,11 @@ #2A2C31 #3A3D43 #2A2C31 - #2D6FD6 #2A2C31 #6B717C + + #1668D0 + #15171C + #FFFFFF + #3A3F4A diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 7404afec..7bd6eb1f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -29,8 +29,12 @@ #ECEEF1 #D5D9DF #EEF0F3 - #1668D0 - #1D9BF0 #E8E9ED #8A909B + + #1668D0 + #15171C + #FFFFFF + #E2E6ED + #FFFFFF diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 04b5f1b3..3e4e9e10 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -23,7 +23,7 @@ 48dp 56dp 24dp - 28dp + 32dp 16dp 72dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 260d9553..8c2a175b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,7 +38,7 @@ 语音输入 麦克风 - 允许麦克风后,按住空格说话,离线识别 + 允许麦克风后,按住空格说话 按住空格说话,松开后识别 麦克风权限已被拒绝,请在系统设置中允许 允许 diff --git a/app/src/test/java/llc/slacker/openime/EmojiAndSymbolCatalogTest.kt b/app/src/test/java/llc/slacker/openime/EmojiAndSymbolCatalogTest.kt new file mode 100644 index 00000000..634ab2db --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/EmojiAndSymbolCatalogTest.kt @@ -0,0 +1,62 @@ +package llc.slacker.openime + +import llc.slacker.openime.core.EmojiCatalog +import llc.slacker.openime.core.ImeData +import llc.slacker.openime.core.SymbolCatalog +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class EmojiAndSymbolCatalogTest { + private fun asset(relative: String): File = sequenceOf( + File("src/main/assets/$relative"), + File("app/src/main/assets/$relative"), + ).first { it.isFile } + + @Test + fun curatedEmojiComeFirstThenTheCatalogAddsTheRest() { + val merged = EmojiCatalog.merge( + curated = linkedMapOf("笑脸" to listOf("😀", "❤️")), + extra = mapOf("笑脸" to listOf("😀", "❤", "🫠"), "旗帜" to listOf("🏁")), + drawable = { true }, + ) + // ❤ and ❤️ are one emoji; categories the panel has no tab for are ignored. + assertEquals(listOf("😀", "❤️", "🫠"), merged["笑脸"]) + assertEquals(setOf("笑脸"), merged.keys) + } + + @Test + fun emojiTheFontCannotDrawAreLeftOut() { + val merged = EmojiCatalog.merge( + curated = linkedMapOf("笑脸" to listOf("😀")), + extra = mapOf("笑脸" to listOf("🫩")), + drawable = { it != "🫩" }, + ) + assertEquals(listOf("😀"), merged["笑脸"]) + } + + @Test + fun theGeneratedCatalogCoversEveryPanelCategoryWithoutSkinTones() { + val rows = asset("emoji/unicode_emoji.tsv").readLines().filter { it.isNotBlank() && !it.startsWith("#") } + val categories = rows.map { it.substringBefore('\t') }.toSet() + assertEquals(ImeData.emojiByCategory.keys, categories) + assertTrue("about 1900 base emoji, got ${rows.size}", rows.size in 1800..2400) + val tones = (0x1F3FB..0x1F3FF).map { String(Character.toChars(it)) } + assertTrue(rows.none { row -> tones.any { it in row } }) + } + + @Test + fun everySymbolListIsFilledAndHasNoRepeats() { + listOf( + SymbolCatalog.CHINESE_EXTRA, SymbolCatalog.SPECIAL_EXTRA, SymbolCatalog.KAOMOJI_EXTRA, + SymbolCatalog.PINYIN_TONES, SymbolCatalog.JAPANESE, SymbolCatalog.ZHUYIN, SymbolCatalog.BOX_DRAWING, + ).forEach { list -> + assertTrue(list.isNotEmpty()) + assertEquals("repeats in ${list.take(3)}", list.size, list.toSet().size) + assertTrue(list.none { it.isBlank() }) + } + assertEquals(SymbolCatalog.CATEGORIES.size, SymbolCatalog.CATEGORIES.toSet().size) + assertTrue(SymbolCatalog.KAOMOJI_EXTRA.size + ImeData.symbols["网络颜文字"].orEmpty().size >= 100) + } +} diff --git a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt index 8853fd7c..9b04bec4 100644 --- a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt +++ b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt @@ -39,9 +39,16 @@ class NineKeyChineseTest { } @Test - fun incompleteDigitOnlyPrefixesStayAsDigitsInsteadOfFakeLetters() { - assertEquals("2", resolve("2").preview) - assertEquals("6", resolve("6").preview) + fun aLoneKeyShowsALetterNotItsDigit() { + // The pre-edit is letters from the first key on; Rime's answer then + // aligns it to the top candidate (4 -> 和 -> h). + assertEquals("a", resolve("2").preview) + assertEquals("o", resolve("6").preview) + listOf("3", "4", "5", "7", "8", "9").forEach { digit -> + val preview = resolve(digit).preview + assertEquals("one letter for $digit", 1, preview.length) + assertEquals(digit, NineKeyLocalDecoder.digitsForPinyin(preview)) + } } @Test diff --git a/app/src/test/java/llc/slacker/openime/NineKeyPreviewLettersTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyPreviewLettersTest.kt new file mode 100644 index 00000000..a4e1870a --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/NineKeyPreviewLettersTest.kt @@ -0,0 +1,73 @@ +package llc.slacker.openime + +import llc.slacker.openime.candidate.CandidateEngine +import llc.slacker.openime.candidate.CandidatePipeline +import llc.slacker.openime.candidate.NineKeyLocalDecoder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.random.Random + +/** + * The nine-key pre-edit shows letters, never digits: beta6 left "669" or a + * whole digit string in it whenever no word read every digit. + */ +class NineKeyPreviewLettersTest { + private val engine = CandidateEngine() + private val pipeline = CandidatePipeline(engine) + private val decoder = NineKeyLocalDecoder(engine) + + private fun assertLettersFor(digits: String, preview: String) { + assertTrue("digits in the pre-edit for $digits: '$preview'", preview.none { it.isDigit() }) + val letters = preview.filter { it in 'a'..'z' } + assertEquals("one letter per digit for $digits: '$preview'", digits.length, letters.length) + assertEquals("letters must type the same digits: '$preview'", digits, NineKeyLocalDecoder.digitsForPinyin(letters)) + } + + @Test + fun everyDigitStringIsSpelledWithLettersOnly() { + val random = Random(20261005) + repeat(3000) { + val length = random.nextInt(1, 33) + val digits = (1..length).map { "23456789"[random.nextInt(8)] }.joinToString("") + assertLettersFor(digits, decoder.spellDigits(digits).joinToString("")) + } + } + + @Test + fun thePreviewNeverFallsBackToDigits() { + val random = Random(7) + val samples = listOf("644264658846649669", "669", "4", "5", "7", "8", "9", "6442646", "999999", "7777", "88888888") + + (1..400).map { (1..random.nextInt(1, 25)).map { "23456789"[random.nextInt(8)] }.joinToString("") } + samples.forEach { digits -> + val preview = pipeline.resolveNineKey(digits, "", null, fuzzy = false).preview + assertLettersFor(digits, preview) + } + } + + @Test + fun anUnfinishedLastSyllableShowsItsStart() { + // 46 starts gong / gou but is no syllable: its letters, not "46". + assertEquals(listOf("go"), decoder.spellDigits("46")) + // Whole syllables where they fit. + assertEquals(listOf("ni", "hao"), decoder.spellDigits("64426")) + } + + @Test + fun aWordSpellingOnlyTheStartAlignsItsPartAndSpellsTheRest() { + // 你好工 for 6442646: the last character typed only up to "go". + assertEquals(listOf("ni", "hao", "go"), decoder.readingForPrefix("6442646", "你好工")) + // 你好 covers 64426; the remaining 46 are spelled, not left as digits. + val rest = decoder.readingForPrefix("6442646", "你好")!! + assertEquals(listOf("ni", "hao"), rest.take(2)) + assertLettersFor("6442646", rest.joinToString("")) + // A word that does not fit the digits at all is no guide. + assertEquals(null, decoder.readingForPrefix("6442646", "我们")) + } + + @Test + fun aWordSpellingEveryDigitStillUsesItsReading() { + assertEquals(listOf("ni", "hao"), decoder.readingForPrefix("64426", "你好")) + assertEquals(listOf("zhong", "guo"), decoder.readingForPrefix("94664486", "中国")) + } +} diff --git a/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt b/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt index 9b230ba3..1c762b68 100644 --- a/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt +++ b/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt @@ -9,7 +9,8 @@ class NoDecorativeGlyphTest { fun interactiveUiDoesNotUseUnicodeAsIcons() { val root = repoRoot() val forbidden = listOf("⌄", "⌃", "✓", "⏹", "🎤", "☺", "›", "▲", "◀", "▶", "▼") - val excluded = setOf("ImeData.kt") + // Symbol and emoji data the user types, not glyphs used as icons. + val excluded = setOf("ImeData.kt", "SymbolCatalog.kt") val violations = File(root, "app/src/main/java") .walkTopDown() .filter { it.isFile && it.extension == "kt" && it.name !in excluded } diff --git a/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt b/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt index ce34024a..51186152 100644 --- a/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt +++ b/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt @@ -81,7 +81,7 @@ class TokenDriftTest { """48dp""", """56dp""", """24dp""", - """28dp""", + """32dp""", """16dp""", """72dp""", ).forEach { expected -> diff --git a/build.gradle.kts b/build.gradle.kts index 1020b258..1c692a4e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,4 @@ plugins { - id("com.android.application") version "8.7.3" apply false - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.android.application") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.4.20" apply false } diff --git a/docs/APP_UI_SPEC.md b/docs/APP_UI_SPEC.md index 92a44c72..e06a99a7 100644 --- a/docs/APP_UI_SPEC.md +++ b/docs/APP_UI_SPEC.md @@ -52,7 +52,7 @@ Apple 的 pt 不是 Android 的 dp/sp。下面是本项目在 Android 上的实 | 分组标题 | 13sp,左缩进 16dp | 距上一组 28dp,距卡片 8dp | | 分组卡片 | 圆角 16dp,无阴影 | 一组设置放在同一张卡里,行与行之间用 1dp 分隔线 | | 分隔线缩进 | 60dp(有图标)/ 56dp(步骤)/ 14dp(键盘内) | 从行文字起点开始,不贯穿图标 | -| 设置行 | 至少 56dp 高,内边距 16/12dp | 图标块 32dp(圆角 9dp,图标 18dp),与文字间隔 12dp | +| 设置行 | 至少 56dp 高,左右内边距都是 16dp | 图标块 32dp(圆角 9dp,图标 18dp),与文字间隔 12dp;开关、数值、按钮和箭头图形的右边缘都落在 16dp 线上(箭头图标框右移 6dp 抵掉自带留白);滑块轨道两端对齐文字列 | | 行文字 | 标签 15sp 中粗,说明 13sp | 标签与说明间隔 4dp;说明允许换行 | | 文字到末端控件 | 至少 12dp | 开关、箭头、数值右对齐 | | 分段选项(偏好设置) | 标签在上,轨道在下占满行宽 | 48dp 点击高度,34dp 可见轨道,选中项 30dp 高 | diff --git a/docs/NINE_KEY_REFERENCE.md b/docs/NINE_KEY_REFERENCE.md index 73864d15..8486bdae 100644 --- a/docs/NINE_KEY_REFERENCE.md +++ b/docs/NINE_KEY_REFERENCE.md @@ -22,6 +22,7 @@ (百度输入法、rime-t9-shiyin 的做法,也是九键「先选拼音再选字」的通行流程)。用户不用为整句选拼音, 整词的读法在预编辑里看,要换字就从候选里选。不提供会让剩余数字无法拼读的选项;孤立的 `a/o/e`、无元音的 `ng/m` 不当读法。早先按设计稿列整条读法(`ni'hao`),与「一个字一个拼音」冲突,已改掉。 + 只有声母的项(单按 6 时的 `m`、`n`)点了同样锁定:候选按这个字母筛选(`m` → 没、么、们)。 2. **锁定的音节发给 Rime 时保持字母**(`xiong'486`)。luna_pinyin 方案同时接受字母和 2–9 数字, 所以 zhong / xiong 这类同数字的读法不会被重新混在一起,候选与所选读法一致。 3. **锁定的音节在继续打字时保持锁定**(用边界封住),退格先解锁最近锁定的音节 / 分词边界。 @@ -30,6 +31,9 @@ 5. **数字刚好拼得出的词排在预测之前。** 输入 `9426`(xian)时 Rime 可能先给 `自从`(zi'cong 的预测); 我们按读法是否恰好耗尽已输入数字分组,组内保持 Rime 顺序,不丢任何候选。 6. **预编辑跟随首选词的读法**(`我想吃饭` → `wo'xiang'chi'fan`),而不是本地解码器独立的猜测。 + 首选词只覆盖前半段时,按它的字取到覆盖不了为止(每个字依次试全拼、截断的读音、声母:`669` → 模型 → `mo'x`), + 剩下的数字再拼成字母。**预编辑任何时候都只有字母,不出现数字**:没有词可依时,本地按「最少的完整音节、 + 末尾允许未打完的音节开头」拼出(`46` → `go`)。 7. **选词只覆盖一部分输入时,只上屏该词,剩余输入继续作为预编辑**(九键 / 26 键一致)。 本地不对部分选词做原生学习(Rime 的整句提交会把没选过的短语写进用户词库)。 8. **空格提交首选词;回车(确定)提交已输入的拼音原文**(Rime / fcitx / Gboard 拼音的约定)。 diff --git a/docs/images/openime-brand.png b/docs/images/openime-brand.png index c43c509c..fde7e86f 100644 Binary files a/docs/images/openime-brand.png and b/docs/images/openime-brand.png differ diff --git a/docs/images/social-preview.png b/docs/images/social-preview.png index 1c8eb0b4..36c54917 100644 Binary files a/docs/images/social-preview.png and b/docs/images/social-preview.png differ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b95..1b33c55b 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f190f30a..34dabfe0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip networkTimeout=600000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 0f448472..23d15a93 100755 --- a/gradlew +++ b/gradlew @@ -15,21 +15,66 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link app_path=$0 + +# Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do - ls=$( ls -ld -- "$app_path" ) + ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( @@ -41,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -65,11 +110,11 @@ nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NonStop* ) nonstop=true ;; + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -105,7 +150,6 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" - ;; esac case $MAX_FD in #( '' | soft) :;; #( @@ -114,24 +158,94 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" - ;; esac fi -# Collect all arguments for the java command, stracks://am the program://ent://s by:// -# temporary://file://to://workaround long://command://line://limits://in://some://shells. -# shellcheck disable=SC2006 +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" -# Stop when "xeli" is in effect, otherwise the shift won't work below. -# Also, the curved brackets here are required by the csh due to the {}. -# shellcheck disable=SC3011 -if [ "${-#*e}" != "$-" ] ; then - set +e +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 9d21a218..db3a6ac2 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/scripts/generate_brand_assets.py b/scripts/generate_brand_assets.py index 5f087dae..adebd204 100755 --- a/scripts/generate_brand_assets.py +++ b/scripts/generate_brand_assets.py @@ -11,41 +11,39 @@ ROOT = Path(__file__).resolve().parents[1] OUTPUT = ROOT / "docs" / "images" -ACCENT = "#6EC3F7" -DARK = "#1C1C1E" -TEXT = "#F2F2F7" - - -def draw_mark(image: Image.Image, box: tuple[int, int, int, int], fill, cutout) -> None: - draw = ImageDraw.Draw(image) +# One brand, as in app/src/main/res: the app accent, ink, and a white tile. +BLUE = "#1668D0" +INK = "#15171C" +TILE = "#FFFFFF" +TILE_EDGE = "#E2E6ED" +PAGE = "#F4F6FA" +SUPERSAMPLE = 4 + + +def draw_mark(image: Image.Image, box: tuple[int, int, int, int]) -> None: + """The IME mark on its white tile, drawn from the 108-unit grid of + ic_launcher_foreground.xml: the I is a text cursor in blue, ME in ink.""" x0, y0, x1, y1 = box - scale = (x1 - x0) / 48.0 - - def point(x: float, y: float) -> tuple[float, float]: - return x0 + x * scale, y0 + y * scale - - draw.rounded_rectangle( - [*point(4, 4), *point(44, 44)], - radius=11 * scale, - fill=fill, - ) - draw.polygon( - [ - point(17, 11), - point(31, 11), - point(31, 14), - point(26, 14), - point(26, 34), - point(31, 34), - point(31, 37), - point(17, 37), - point(17, 34), - point(22, 34), - point(22, 14), - point(17, 14), - ], - fill=cutout, - ) + size = x1 - x0 + big = Image.new("RGBA", (size * SUPERSAMPLE, size * SUPERSAMPLE), (0, 0, 0, 0)) + draw = ImageDraw.Draw(big) + unit = size * SUPERSAMPLE / 108.0 + + def p(x: float, y: float) -> tuple[float, float]: + return x * unit, y * unit + + draw.rounded_rectangle([*p(2, 2), *p(106, 106)], radius=26 * unit, fill=TILE, outline=TILE_EDGE, width=max(1, round(1.5 * unit))) + # I: stem and two serifs with 1.5-unit corners. + draw.rectangle([*p(31, 41), *p(36, 67)], fill=BLUE) + draw.rounded_rectangle([*p(27, 41), *p(40, 45)], radius=1.5 * unit, fill=BLUE) + draw.rounded_rectangle([*p(27, 63), *p(40, 67)], radius=1.5 * unit, fill=BLUE) + # M and E. + draw.polygon([p(*xy) for xy in [(44, 67), (44, 41), (50, 41), (54, 52), (58, 41), (64, 41), (64, 67), (58, 67), + (58, 53), (55.5, 60), (52.5, 60), (50, 53), (50, 67)]], fill=INK) + draw.polygon([p(*xy) for xy in [(68, 41), (82, 41), (82, 47), (74, 47), (74, 51), (80, 51), (80, 57), (74, 57), + (74, 61), (82, 61), (82, 67), (68, 67)]], fill=INK) + mark = big.resize((size, size), Image.LANCZOS) + image.paste(mark, (x0, y0), mark) def load_font(size: int) -> ImageFont.ImageFont: @@ -63,20 +61,20 @@ def load_font(size: int) -> ImageFont.ImageFont: def generate_brand_png() -> None: image = Image.new("RGBA", (288, 288), (0, 0, 0, 0)) - draw_mark(image, (0, 0, 288, 288), ACCENT, (0, 0, 0, 0)) + draw_mark(image, (0, 0, 288, 288)) image.save(OUTPUT / "openime-brand.png", optimize=True) def generate_social_preview() -> None: - image = Image.new("RGB", (1280, 640), DARK) - draw_mark(image, (520, 105, 760, 345), ACCENT, DARK) + image = Image.new("RGB", (1280, 640), PAGE) + draw_mark(image, (520, 105, 760, 345)) draw = ImageDraw.Draw(image) label = "openIME" font = load_font(92) bounds = draw.textbbox((0, 0), label, font=font) width = bounds[2] - bounds[0] - draw.text(((1280 - width) / 2, 400), label, font=font, fill=TEXT) + draw.text(((1280 - width) / 2, 400), label, font=font, fill=INK) image.save(OUTPUT / "social-preview.png", optimize=True) diff --git a/scripts/generate_emoji_catalog.py b/scripts/generate_emoji_catalog.py new file mode 100644 index 00000000..71c184e5 --- /dev/null +++ b/scripts/generate_emoji_catalog.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Generate openIME's full emoji list from Unicode's emoji-test.txt. + + curl -sSfLO https://unicode.org/Public/emoji/latest/emoji-test.txt + python3 scripts/generate_emoji_catalog.py emoji-test.txt + +Writes app/src/main/assets/emoji/unicode_emoji.tsv: one "categoryemoji" +line per fully-qualified emoji, in Unicode's order, without skin-tone or +hair variants (the panel shows each emoji once). The emoji panel keeps its +curated order and appends what this list adds; emoji the phone's font cannot +draw are dropped at runtime. Unicode data terms: +https://www.unicode.org/terms_of_use.html +""" + +import re +import sys +from pathlib import Path + +OUT = Path(__file__).resolve().parent.parent / "app" / "src" / "main" / "assets" / "emoji" / "unicode_emoji.tsv" + +# Unicode emoji groups -> the panel's categories (ImeData.emojiByCategory keys). +GROUPS = { + "Smileys & Emotion": "笑脸", + "People & Body": "人物/手势", + "Animals & Nature": "动物/自然", + "Food & Drink": "食物/饮品", + "Travel & Places": "旅行/地点", + "Activities": "活动", + "Objects": "物品", + "Symbols": "符号", + "Flags": "旗帜", +} +# Skin tones and hair styles: the panel lists the base emoji only. +VARIANTS = set(range(0x1F3FB, 0x1F400)) | {0x1F9B0, 0x1F9B1, 0x1F9B2, 0x1F9B3} + + +def main(): + source = Path(sys.argv[1]) + text = source.read_text(encoding="utf-8") + version = re.search(r"^# Version: (\S+)", text, re.M).group(1) + group = None + rows = [] + for line in text.splitlines(): + if line.startswith("# group: "): + group = GROUPS.get(line[len("# group: "):].strip()) + continue + if group is None or not line or line.startswith("#"): + continue + codes, _, rest = line.partition(";") + if not rest.strip().startswith("fully-qualified"): + continue + points = [int(code, 16) for code in codes.split()] + if any(point in VARIANTS for point in points): + continue + rows.append((group, "".join(chr(point) for point in points))) + OUT.write_text( + f"# Unicode emoji {version}, fully-qualified, no skin-tone or hair variants.\n" + "# Generated by scripts/generate_emoji_catalog.py from emoji-test.txt; do not edit.\n" + + "".join(f"{category}\t{emoji}\n" for category, emoji in rows), + encoding="utf-8", + ) + print(f"{len(rows)} emoji (Unicode {version}) -> {OUT.relative_to(OUT.parents[5])}") + + +if __name__ == "__main__": + main() diff --git a/scripts/typing_stress_e2e.py b/scripts/typing_stress_e2e.py index 4efbddd9..27c5faee 100644 --- a/scripts/typing_stress_e2e.py +++ b/scripts/typing_stress_e2e.py @@ -35,6 +35,7 @@ RECEIVER = f"{PKG}/{CLASSES}.E2ETestReceiver" ACTION = f"{CLASSES}.TEST_COMMAND" FIELD = "lab_multiline" +FIELD_HINT = "多行文本" ROOT = Path(__file__).resolve().parent.parent STROKE_TABLE = ROOT / "app" / "build" / "generated" / "assets" / "prebuildRimeData" / "stroke_table.tsv" @@ -108,7 +109,9 @@ def field_text(self) -> str: continue for el in root.iter("node"): if el.get("resource-id", "").endswith(f"/{FIELD}"): - return el.get("text", "") + # uiautomator reports an empty field's hint as its text. + text = el.get("text", "") + return "" if text == el.get("hint", FIELD_HINT) or text == FIELD_HINT else text return "" def field_bounds(self) -> tuple[int, int, int, int] | None: @@ -117,6 +120,18 @@ def field_bounds(self) -> tuple[int, int, int, int] | None: match = re.search(rf'resource-id="[^"]*/{FIELD}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml) return tuple(map(int, match.groups())) if match else None + def preedit(self) -> str | None: + """The keyboard's own pre-edit line (the IME window's text field), or None.""" + self.shell("uiautomator", "dump", "--windows", "/sdcard/stress-ime.xml") + xml = self.shell("cat", "/sdcard/stress-ime.xml") + values = re.findall( + rf']*text="([^"]*)"[^>]*resource-id="[^"]*"[^>]*class="android.widget.EditText"[^>]*package="{re.escape(PKG)}"', + xml, + ) + hint = re.search(r'resource-id="[^"]*/' + FIELD, xml) + # The IME window comes first in the dump; the lab fields follow it. + return values[0] if values and not (hint and xml.find(values[0]) > hint.start()) else None + def shot(self, name: str) -> None: (self.out / f"{name}.png").write_bytes(self.run("exec-out", "screencap", "-p", binary=True)) @@ -162,13 +177,65 @@ def type_pinyin26(dev: Device, words: list[str]) -> None: time.sleep(0.15) -def type_nine_key(dev: Device, words: list[str]) -> None: +def type_nine_key(dev: Device, words: list[str], check_preedit: bool = False) -> list[str]: + """Type each word's digits and commit; with [check_preedit], report any pre-edit holding digits.""" + problems = [] for word in words: for letter in word.replace("v", "u"): dev.tap_tag(f"key-9:{T9[letter]}") time.sleep(0.4) + if check_preedit: + shown = dev.preedit() + if shown is None or any(ch.isdigit() for ch in shown): + problems.append(f"{word}: pre-edit {shown!r}") dev.tap_tag("key-space") time.sleep(0.15) + return problems + + +RAIL_TAGS = ("nine-pinyin-path-filter", "nine-pinyin-path-selected") + + +def check_nine_key_rail(dev: Device, sequences: list[str]) -> list[str]: + """Tap every reading the left rail offers: each must change the pre-edit or the candidates.""" + problems = [] + for digits in sequences: + index = 0 + while True: + dev.tap_tag("key-retype") + for digit in digits: + dev.tap_tag(f"key-9:{digit}") + time.sleep(0.9) + dev.refresh_bounds() + rail = [b for b in dev.bounds if b[0] in RAIL_TAGS] + if index >= len(rail): + break + name = rail[index][1] + symbols_y = next(b[3] for b in dev.bounds if b[0] == "key-symbols") + for _ in range(4): # scroll the rail until the reading is above the 符号 key + hit = next((b for b in dev.bounds if b[0] in RAIL_TAGS and b[1] == name), None) + if hit is None or hit[3] < symbols_y - 90: + break + dev.shell("input", "swipe", str(int(hit[2])), str(int(symbols_y - 120)), + str(int(hit[2])), str(int(symbols_y - 420)), "250") + time.sleep(0.5) + dev.refresh_bounds() + if hit is None or hit[3] >= symbols_y - 90: + problems.append(f"{digits}: {name} unreachable") + index += 1 + continue + before = (dev.preedit(), [b[1] for b in dev.bounds if b[0] == "candidate-first-row"]) + dev.shell("input", "tap", str(int(hit[2])), str(int(hit[3]))) + time.sleep(0.9) + dev.refresh_bounds() + after = (dev.preedit(), [b[1] for b in dev.bounds if b[0] == "candidate-first-row"]) + if after[0] is None or any(ch.isdigit() for ch in after[0]): + problems.append(f"{digits}: after {name} the pre-edit is {after[0]!r}") + elif after == before and "已选择" not in name: + problems.append(f"{digits}: tapping {name} changed nothing ({before})") + index += 1 + dev.tap_tag("key-retype") + return problems def stroke_codes(count: int) -> list[tuple[str, str]]: @@ -222,6 +289,52 @@ def probe_preedit_stays_on_keyboard(dev: Device, keys: list[str]) -> str | None: return None +def visible_tags(dev: Device, predicate) -> list[tuple[str, str, float, float]]: + """Bounds entries matching [predicate] whose centre is on screen above the navigation bar.""" + height = int(re.search(r"(\d+)x(\d+)", dev.shell("wm", "size")).group(2)) + return [b for b in dev.bounds if predicate(b) and 0 < b[3] < height - 120] + + +def type_from_symbol_panel(dev: Device, categories: list[str], per_category: int = 2) -> str: + """Open 符号, tap the first symbols of each category; returns what should have been typed.""" + expected = "" + dev.tap_tag("key-symbols") + time.sleep(1.0) + for category in categories: + dev.command(f"tap:{category}") + time.sleep(0.8) + dev.refresh_bounds() + keys = visible_tags(dev, lambda b: b[0].startswith("key:"))[:per_category] + for tag, desc, x, y in keys: + dev.shell("input", "tap", str(int(x)), str(int(y))) + time.sleep(0.25) + expected += tag[len("key:"):] + dev.shell("input", "keyevent", "BACK") + time.sleep(0.6) + return expected + + +def type_emoji(dev: Device, count: int = 6) -> str: + dev.command("tap:表情") + time.sleep(1.2) + dev.refresh_bounds() + cells = visible_tags(dev, lambda b: b[0] == "emoji-cell")[:count] + expected = "" + for _, desc, x, y in cells: + dev.shell("input", "tap", str(int(x)), str(int(y))) + time.sleep(0.25) + expected += desc + dev.shell("input", "keyevent", "BACK") + time.sleep(0.6) + return expected + + +def set_rotation(dev: Device, landscape: bool) -> None: + dev.shell("settings", "put", "system", "accelerometer_rotation", "0") + dev.shell("settings", "put", "system", "user_rotation", "1" if landscape else "0") + time.sleep(2) + + def is_cjk(text: str) -> bool: return all("㐀" <= ch <= "鿿" or "\U00020000" <= ch <= "\U0003134f" for ch in text) @@ -240,8 +353,13 @@ def run_case(dev: Device, name: str, minimum: int) -> tuple[bool, str]: elif name == "pinyin9": set_mode(dev, "PINYIN_9") probe = probe_preedit_stays_on_keyboard(dev, [f"key-9:{d}" for d in "669"]) - type_nine_key(dev, PINYIN_WORDS) + digit_problems = type_nine_key(dev, PINYIN_WORDS, check_preedit=True) + probe = probe or ("; ".join(digit_problems) if digit_problems else None) chinese = True + elif name == "nine-rail": + set_mode(dev, "PINYIN_9") + problems = check_nine_key_rail(dev, ["2", "4", "6", "9", "64", "74", "646", "6464", "94664", "6442646", "644264658846649669"]) + return (not problems), ("; ".join(problems) if problems else "every rail reading responds, pre-edit always letters") elif name == "stroke": set_mode(dev, "STROKE") probe = probe_preedit_stays_on_keyboard(dev, [f"key-stroke:{c}" for c in "phz"]) @@ -273,6 +391,81 @@ def run_case(dev: Device, name: str, minimum: int) -> tuple[bool, str]: type_digits(dev, "2026100512") set_mode(dev, "PINYIN_26") type_pinyin26(dev, PINYIN_WORDS[16:24]) + elif name == "symbols": + set_mode(dev, "PINYIN_9") + expected = type_from_symbol_panel( + dev, ["常用", "中文", "英文", "数学", "序号", "特殊", "网络颜文字", "拼音", "日文", "注音", "制表", "单位", "编程"], + ) + minimum = min(minimum, len(expected)) + elif name == "emoji": + set_mode(dev, "PINYIN_26") + expected = type_emoji(dev, 8) + minimum = min(minimum, len(expected)) + elif name == "rail": + set_mode(dev, "PINYIN_9") + cells = visible_tags(dev, lambda b: b[0].startswith("punct:") and b[0] != "punct:add")[:3] + expected = "" + for tag, _, x, y in cells: + dev.shell("input", "tap", str(int(x)), str(int(y))) + time.sleep(0.25) + expected += tag[len("punct:"):] + dev.tap_tag("key-9:0") + expected += "0" + minimum = min(minimum, len(expected)) + elif name == "expand": + set_mode(dev, "PINYIN_26") + picked = "" + for word in ["shi", "zhong", "hao", "de", "ren"]: + for letter in word: + dev.tap_tag(f"key:{letter}") + time.sleep(0.5) + dev.refresh_bounds() + dev.tap_tag("candidate-expand") + time.sleep(0.8) + dev.refresh_bounds() + grid = visible_tags(dev, lambda b: b[0] in ("candidate-grid", "candidate-grid-first")) + if len(grid) < 3: + return False, f"expanded candidates for {word}: only {len(grid)}" + tag, desc, x, y = grid[2] + dev.shell("input", "tap", str(int(x)), str(int(y))) + time.sleep(0.5) + dev.refresh_bounds() + picked += desc.removeprefix("候选:") + expected = picked + minimum = min(minimum, len(expected)) + elif name == "clear": + set_mode(dev, "PINYIN_26") + type_pinyin26(dev, PINYIN_WORDS[:6]) + dev.refresh_bounds() + hit = next(b for b in dev.bounds if b[0] == "key-backspace") + dev.shell("input", "swipe", str(int(hit[2])), str(int(hit[3])), str(int(hit[2])), str(int(hit[3] - 330)), "300") + time.sleep(1) + text = dev.field_text() + dev.shot(name) + return (text == ""), f"after swipe-up clear the field holds {text!r}" + elif name in ("landscape", "dark"): + if name == "landscape": + set_rotation(dev, True) + else: + dev.shell("cmd", "uimode", "night", "yes") + time.sleep(1.5) + try: + show_keyboard(dev) + clear_field(dev) + set_mode(dev, "PINYIN_26") + type_pinyin26(dev, PINYIN_WORDS[:14]) + set_mode(dev, "PINYIN_9") + type_nine_key(dev, PINYIN_WORDS[14:28]) + time.sleep(1) + text = dev.field_text() + dev.shot(name) + finally: + if name == "landscape": + set_rotation(dev, False) + else: + dev.shell("cmd", "uimode", "night", "no") + ok = len(text) >= minimum and is_cjk(text) + return ok, f"{len(text)} characters: {text[:40]}…" else: return False, f"unknown case {name}" time.sleep(1) @@ -297,7 +490,10 @@ def main() -> int: parser.add_argument("--out", type=Path, default=ROOT / "build" / "typing-stress") parser.add_argument("--min", type=int, default=50) parser.add_argument("--package", default=PKG, help="applicationId of the debug build (e.g. with a .dev suffix)") - parser.add_argument("cases", nargs="*", default=["pinyin26", "pinyin9", "stroke", "english", "digits", "mixed"]) + parser.add_argument("cases", nargs="*", default=[ + "pinyin26", "pinyin9", "nine-rail", "stroke", "english", "digits", "mixed", + "symbols", "emoji", "rail", "expand", "clear", "landscape", "dark", + ]) args = parser.parse_args() PKG = args.package IME = f"{PKG}/{CLASSES}.LocalVoiceImeService"