4 次代码提交
共修改 110 个文件,包含 3206 行新增和 2519 行删除

没有与搜索条件相匹配的文件

+2
查看文件
@@ -0,0 +1,2 @@
# Preserve the exact bytes recorded by PUBLIC_SOURCE_MANIFEST.json.
* -text -eol
+8
查看文件
@@ -3,3 +3,11 @@
**/src/assets/ime-dict/ **/src/assets/ime-dict/
temp/ temp/
work/ work/
**/.gradle/
**/build/
**/local.properties
**/dictionary.db
**/release-signing.properties
**/*.jks
**/*.keystore
!android/gradle/wrapper/gradle-wrapper.jar
+1 -1
查看文件
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 herry Copyright (c) 2026 sydxky
Copyright (c) 2026 kqakqakqa Copyright (c) 2026 kqakqakqa
Copyright (c) 2026 crabKun Copyright (c) 2026 crabKun
+5 -5
查看文件
@@ -1,6 +1,6 @@
# 蟹蟹输入 RTOS适配改版 # 蟹蟹输入 RTOS适配改版
本项目为基于[CrabKeyboard / 蟹蟹输入](https://github.com/crabPangXie/crabKeyboard)的独立RTOS适配改版,由herry进行Glyphix页面、离线词库索引、九宫/14键消歧、标准QWERTY、候选分页及生命周期适配。不是上游原版,也不表示上游作者、华为、OPPO或Glyphix对本改版作出背书。 本项目为基于[CrabKeyboard / 蟹蟹输入](https://github.com/crabPangXie/crabKeyboard)的独立RTOS适配改版,由sydxky进行Glyphix页面、离线词库索引、九宫/14键消歧、标准QWERTY、候选分页及生命周期适配。不是上游原版,也不表示上游作者、华为、OPPO或Glyphix对本改版作出背书。
上游固定提交:`93d36b5002dee80a3d253b1ec7ec9e29800a104c`。保留原作者kqakqakqa、crabKun的版权声明、MIT许可证及原始词库/读取器。RTOS自研适配部分以根目录[MIT许可证](./LICENSE)发布,**允许商用、修改、再分发和闭源集成**,须保留适用版权及许可全文。 上游固定提交:`93d36b5002dee80a3d253b1ec7ec9e29800a104c`。保留原作者kqakqakqa、crabKun的版权声明、MIT许可证及原始词库/读取器。RTOS自研适配部分以根目录[MIT许可证](./LICENSE)发布,**允许商用、修改、再分发和闭源集成**,须保留适用版权及许可全文。
@@ -8,10 +8,10 @@
| 内容 | 作者/项目 | 许可与位置 | | 内容 | 作者/项目 | 许可与位置 |
| --- | --- | --- | | --- | --- | --- |
| CrabKeyboard V5词库和原始读取器 | kqakqakqa、crabKun | MIT,`com.sydxky.rtos.inputmethod/third_party/crabKeyboard/LICENSE` | | CrabKeyboard V5词库和原始读取器 | kqakqakqa、crabKun | MIT,`third_party/crabKeyboard/LICENSE` |
| 本地候选排序词频 | jieba 0.42.1 / Sun Junyi | MIT,`com.sydxky.rtos.inputmethod/third_party/jieba/LICENSE` | | 本地候选排序词频 | jieba 0.42.1 / Sun Junyi | MIT,`third_party/jieba/LICENSE` |
| 操作图标 | Remix Icon v4.6.0,对应iconfont公开矢量 | Apache-2.0,`com.sydxky.rtos.inputmethod/third_party/remix-icon/LICENSE`;矢量改色并转换PNG,来源/哈希见同目录`icons.json` | | 操作图标 | Remix Icon v4.6.0,对应iconfont公开矢量 | Apache-2.0,`third_party/remix-icon/LICENSE`;矢量改色并转换PNG,来源/哈希见同目录`icons.json` |
14键参考公开演示中的相邻字母分组,自行实现离线消歧;不包含华为内部模型或固件代码。Glyphix/厂商SDK与运行时不包含在本项目MIT授权中,也不随源码分发,使用者需自行取得适用的厂商授权。 14键参考公开演示中的相邻字母分组,自行实现离线消歧;不包含华为内部模型或固件代码。Glyphix/厂商SDK与运行时不包含在本项目MIT授权中,也不随源码分发,使用者需自行取得适用的厂商授权。
公开仓库:<https://git2.sydxky.cn/herry/crab-input-rtos>。匿名问题与建议:<https://git2.sydxky.cn/feedback/herry/crab-input-rtos>。 公开仓库:<https://git2.sydxky.cn/sydxky/crab-input-rtos>。匿名问题与建议:<https://git2.sydxky.cn/feedback/sydxky/crab-input-rtos>。
+127 -43
查看文件
@@ -1,37 +1,85 @@
{ {
"project": "蟹蟹输入 RTOS适配改版", "project": "蟹蟹输入 RTOS / Android手表适配改版",
"version": "1.1.0", "version": "1.2.4",
"license": "MIT", "license": "MIT",
"android": true,
"independent_history": true, "independent_history": true,
"sensitive_findings": [],
"files": { "files": {
".gitignore": "5fc1922c8c233ebab1a7a6d3f15f83a727039ad0b5be612bcb274706080c37fc", ".gitattributes": "8e689518e5810501eb34583fa369475902805d8dd6c3ea78699469de4fa22932",
"LICENSE": "fbe0034e81020ac52847583ed5a8dab0e212266623b475381c0a468a800f67df", ".gitignore": "cf35fe4d5c1c2ca6ae6bd343642fee3d04cd8d3799510a02ca94df73a2d4daad",
"NOTICE.md": "c1259ed34498181695d1f04ba60301124525a7e84d383c698fd0202169b652b7", "android/.gitignore": "9907f78ab6ad68ac162f15ed5554c0ea9bafecf6eb25f2388ba0c11d40fab35d",
"README.md": "42d8bfa45434241de50ca6811af21cdeff01ca183885dcceb284db474e582383", "android/app/build.gradle": "9d0b476141e72df003d3232561fe30890d93c114197e03f614866c45c3d169b5",
"build-rtos-game.ps1": "207ff9875af96221559f314364c932fe37bfc22c698ad1afabb9aeec40a8679e", "android/app/proguard-rules.pro": "41e72ccc70b31f2da13fc597d5196590ec25e0422c2766e1555f3348d64ac6ed",
"com.sydxky.rtos.imefixture/build.ps1": "36b41ff37b153c1030756e641d2bb8d8a5404425905897e1f99e728c96493dad", "android/app/proguard-test.pro": "dc5f87f1ec90eb24823174f80d19e93d3e69919af70891529e4c54a744988aa4",
"com.sydxky.rtos.imefixture/glyphix.config.js": "c402178dc7457c56d9762b464a868b0c9ade304597d81ed46f0af04971fcddcf", "android/app/src/androidTest/java/com/sydxky/crabinput/ImeDeviceTest.java": "d5a7284b6693ee116a9e5450b4c0e38df2a71812c79664ee04311d8088db33ac",
"com.sydxky.rtos.imefixture/src/app.js": "fc00d19bc94e10c24f07bd9be1c2bb24d4978856963c5a68745338ecbf131dfc", "android/app/src/main/AndroidManifest.xml": "cd096a1f1bd560114c865260bbdc50f43971089598968b065581cf07f1dbee69",
"android/app/src/main/java/com/sydxky/crabinput/MainActivity.java": "a777366f52e5974f55e1298f2d8b6cd24dbf94fb8d5a57c25d9ac4b491fe6e36",
"android/app/src/main/res/drawable/ic_launcher.xml": "9f22878890b584bb6151223cc280e542f82f354cc69e1d47f2dc6093a606c071",
"android/app/src/main/res/values/styles.xml": "37c2bd1bbf1017e84448c0177f8999fd44f5cbca8c0afde5478594dc7be55279",
"android/build.gradle": "79f56a485c696a3b800776255c37729515464340f568bf5163e5f81b60086198",
"android/build.ps1": "3dc3c31675d3cf946f1cfce6d6e2a17493416be9fce4565cdcc78515345006b5",
"android/gradle/wrapper/gradle-wrapper.jar": "e996d452d2645e70c01c11143ca2d3742734a28da2bf61f25c82bdc288c9e637",
"android/gradle/wrapper/gradle-wrapper.properties": "f22c1dc156f94b33fa7706633e18a7ea6e87038bfc8ac0f6f4785cb1511700f0",
"android/gradle.properties": "1a62f4c0a2a08044c706c378451342a11b307842b6b2ad434a86d6f970acc201",
"android/gradlew": "80a4c62723bb67cbabe92c37606d380089c647184afa1bf2b8c136f0e15d2cd6",
"android/gradlew.bat": "0e82fa239f8f4dca4efc83863c16c3b143cb6faab6f50cbefab4ee0bd03e5b5f",
"android/host/build.gradle": "9bede276d4c1b8f7229307d8ba9ae569472f7470a3e0dc743d9c9f0ca4fb3615",
"android/host/src/main/AndroidManifest.xml": "2013ca2761bc091216dca86d6b14987147e169902c4e784a2a047d473a7c6c95",
"android/host/src/main/java/com/sydxky/crabinput/host/HostActivity.java": "91619d8681376727fd8459157db5dc1e84d261134fc3c8c9354ec01f52fafe7d",
"android/keyboard/build.gradle": "512edae398ac76078488f9d2439e23032029b44b4d1bbbbe266d9e2d11effedc",
"android/keyboard/consumer-rules.pro": "74ec127457bb863b30cc41be93b5f6b1eb003783addb2cda36b3fa1cf7e80432",
"android/keyboard/src/androidTest/java/com/sydxky/crabinput/SurfaceRegressionTest.java": "d92ab17b0872ec6bbd6eecee897c1376d42721c86dd380e2b0334b95055370ce",
"android/keyboard/src/main/AndroidManifest.xml": "343d5be1ac17cdd45d1a2f2ddc616c509290e655869125a9422a96d88aa66066",
"android/keyboard/src/main/assets/dictionary-manifest.json": "aab6bf005ec08396e877163c4edb835299946b99362df97ab32ded80eb3e78a4",
"android/keyboard/src/main/assets/licenses/CrabInput-LICENSE": "027e84ad11fd2992040da0aad2ece7a74fdf61540dc2d74cc8a281fd76eb942c",
"android/keyboard/src/main/assets/licenses/CrabInput-NOTICE.txt": "bba74020843ad841f979d346d13625e26a2cb1c0e9a2031f1a45e62957f132ab",
"android/keyboard/src/main/assets/licenses/crabKeyboard-LICENSE.txt": "b140337f94481310f35abfd2210fde308ba30411899095fd961a77b94e022775",
"android/keyboard/src/main/assets/licenses/Gradle-LICENSE.txt": "cf234c4188d773406f640bf89be680d9288f40ff96901c70d643fcea96ad46df",
"android/keyboard/src/main/assets/licenses/jieba-LICENSE.txt": "18ba0984839f85853b29fadaf992f7dba8fd0ca0fbeae34de2b8735222dc7a37",
"android/keyboard/src/main/java/com/sydxky/crabinput/Candidate.java": "ce0ed27cb8d69d14595f6e3cbc0ac27bfac3245b8fb7f1562540054a98c81e37",
"android/keyboard/src/main/java/com/sydxky/crabinput/CandidatePager.java": "a5071ddec3cee9899b80181ef1f35c6896cdd1455df37839b600a33c29ea640e",
"android/keyboard/src/main/java/com/sydxky/crabinput/CandidateRanking.java": "dbd1e7cbda98225eb2f9b1361508a8f22f403c6a9a8690cc64dec34ffd0c78fc",
"android/keyboard/src/main/java/com/sydxky/crabinput/CrabKeyboard.java": "1f03cda3833ba282d12eaf66ddcbb5f4354bb8b99a1d2c9de1b4f430c9effcac",
"android/keyboard/src/main/java/com/sydxky/crabinput/KeyboardController.java": "8427a7d3dbee68031ee5964ad7f22cf64d94cf70049b7e05ae2ec2aca63fade8",
"android/keyboard/src/main/java/com/sydxky/crabinput/KeyboardState.java": "e5253607c117d18084ca1167868b3ef7ef0f28529700b70507c70af531d17291",
"android/keyboard/src/main/java/com/sydxky/crabinput/KeyboardSurface.java": "1ac71672ca55856a5dbbcbde4af243274b0c59d71cb74fd677ad6ea63815790e",
"android/keyboard/src/main/java/com/sydxky/crabinput/Lexicon.java": "a0e0909184bdb97b5fd8773d6f32b1ae9dee8c96ce589c37d6cabdbcfbb63cbd",
"android/keyboard/src/test/java/com/sydxky/crabinput/CandidatePagerTest.java": "7c0447f5eebea944df38179aafd789c4f2eae25ba53aff5ba9e92a2d46044c64",
"android/keyboard/src/test/java/com/sydxky/crabinput/CandidateRankingTest.java": "3e163c8137829de2f26fa737b4a983f0bb4544c0598f2b635e3ddc55631bcb74",
"android/LICENSE": "4f3547e23d7fd227a33aaa6a075185be8299403b03e534627bbb3bc3bdcf97af",
"android/NOTICE.md": "bba74020843ad841f979d346d13625e26a2cb1c0e9a2031f1a45e62957f132ab",
"android/README.md": "490bf2b1cd03a2aa11cc806edccd057876c84b3778dd7ddfe3d31831081a9442",
"android/scripts/build-dictionary.py": "4643f5422f2383a56d8c0653e47e3fc6b02481f065efd924958569fb48435969",
"android/scripts/capture-device.py": "34023ff4fb0ff80a8811b1222aad3b58c70b628333edb56efffa3e6adaf05ace",
"android/scripts/validate-device.py": "149f73cc3b6174f0487d95b2bd03e6792f4897036331bb8e0c1012b07dca6644",
"android/settings.gradle": "73a5f1e8c25c44b0c5f983ed822c4256dea33b14b8d53115ed3be118aeaaee1d",
"android/VALIDATION_1.2.0.md": "7ce04837f631f9e491ecfb96e8e056c4b01c4aa2f24a061d540c5280201848e6",
"android/VALIDATION_1.2.2.md": "6d3ebe3f107099516d2dbcae025e41d516ba8d4de4a40fc9af3342ba9ac9d345",
"android/VALIDATION_1.2.3.md": "603ed3903589f4718892ee4211d59c563c638ea3547c28df27f5eef04651bd33",
"android/VALIDATION_1.2.4.md": "6ec3e7e2ea9d19d7e5393a8af17eb5d3e76a2db382b70f788fddac259482cb98",
"build-rtos-game.ps1": "a0cf9d6fa5ba1ecc22e768373dcca2f87e1e58fc44f0a612b59c7215fb064df8",
"com.sydxky.rtos.imefixture/build.ps1": "37cd2f3af83d9c1cb2f1d855d0d95cf2e822ac9e743c8370237c48c4a4d8c0a0",
"com.sydxky.rtos.imefixture/glyphix.config.js": "d53b4b0e8597d9f7fb5fd3d53063d285b245669021527a1bfc3bc054c5122514",
"com.sydxky.rtos.imefixture/src/app.js": "abde5172a3f0254b611346598b6f9ce9e15a01ffcf6d770ce9e5fe095a781334",
"com.sydxky.rtos.imefixture/src/assets/icon.png": "f712ac5f8c500a33dd72cc1f15f9ad467e6043c284fb6ece1f1683e54d40e683", "com.sydxky.rtos.imefixture/src/assets/icon.png": "f712ac5f8c500a33dd72cc1f15f9ad467e6043c284fb6ece1f1683e54d40e683",
"com.sydxky.rtos.imefixture/src/main/index.ux": "d71f1cb5e0342cbb9e9559760631046887c942ecb75436a450acef7c493ec9de", "com.sydxky.rtos.imefixture/src/main/index.ux": "7a38495cbc36f6ce23dcf45ac7ddbce70c92246749e4963efddbfb2aa9ebe269",
"com.sydxky.rtos.imefixture/src/manifest.json": "40d7d1464409121b5c06ee77e3ff78d38c3f5271701cd754028cae5b5ef488c0", "com.sydxky.rtos.imefixture/src/manifest.json": "3d6ba4288a0653bc8db541ca03999240bc99ece9ea1d056b7ad8cf46a6039bbf",
"com.sydxky.rtos.inputmethod/.gitignore": "0f139de1ca71ff6aa1e6f02974b5e38d219d7781d4184d524845dce12d947454", "com.sydxky.rtos.inputmethod/.gitignore": "0f139de1ca71ff6aa1e6f02974b5e38d219d7781d4184d524845dce12d947454",
"com.sydxky.rtos.inputmethod/DESIGN.md": "5ba2d52755e9852be46f883507376fade0fbb006b44afc0cccf12447b8ae5a79",
"com.sydxky.rtos.inputmethod/KEYBOARD_14.md": "27ae1fa73dd8fa8fce70743fa1e418b4a4ff71ba610e735f009344896adfa754",
"com.sydxky.rtos.inputmethod/LICENSE": "fbe0034e81020ac52847583ed5a8dab0e212266623b475381c0a468a800f67df",
"com.sydxky.rtos.inputmethod/NOTICE.md": "a16fb81a4ac41e6ebd8730c76194def41ba1abf61221d5c8f658907287c6ac03",
"com.sydxky.rtos.inputmethod/PRIVACY.md": "c3062d8434db2fad3b6824cf2c41561a0bf6ae05e62f05d7c6316efd64d74af9",
"com.sydxky.rtos.inputmethod/README.md": "5b652f6892960eb6d9fe00d1c64207fc9872c7d6f5e5ddaef8ec3590d4a390b6",
"com.sydxky.rtos.inputmethod/VALIDATION_1.1.0.md": "891ea8683d0d1cc56d548ba9a7dc66fe18db5d68a94b5f6a7f11f1ad48c0c849",
"com.sydxky.rtos.inputmethod/build.ps1": "32168e753699f48274fe2492e53b7285e4b62c15dc928f3aeb366a11150ebf5a", "com.sydxky.rtos.inputmethod/build.ps1": "32168e753699f48274fe2492e53b7285e4b62c15dc928f3aeb366a11150ebf5a",
"com.sydxky.rtos.inputmethod/DESIGN.md": "ea248cc86069dceaf51c834935a8233501f6855aeaff6e89f432357c17ec5848",
"com.sydxky.rtos.inputmethod/glyphix.config.js": "43beae1f2a61eed010149ae6ad77223ba084117ea5aee3cb3fcd48bfc0a4a32b", "com.sydxky.rtos.inputmethod/glyphix.config.js": "43beae1f2a61eed010149ae6ad77223ba084117ea5aee3cb3fcd48bfc0a4a32b",
"com.sydxky.rtos.inputmethod/KEYBOARD_14.md": "27ae1fa73dd8fa8fce70743fa1e418b4a4ff71ba610e735f009344896adfa754",
"com.sydxky.rtos.inputmethod/LICENSE": "4f3547e23d7fd227a33aaa6a075185be8299403b03e534627bbb3bc3bdcf97af",
"com.sydxky.rtos.inputmethod/NOTICE.md": "eb75c7be9055410bec57688b363be4761c2ee6f2ae4cfba4ab908d0b9ce68999",
"com.sydxky.rtos.inputmethod/PRIVACY.md": "19049b0bb30add6fa5071ec16e9494f835e04ee1dad83108bfec34c71e16d679",
"com.sydxky.rtos.inputmethod/README.md": "380ebdad19cc27d9358c802d1c3f5c899dad4e0f387ad8a1d5fcc496c29a51e9",
"com.sydxky.rtos.inputmethod/scripts/build-icons.py": "51f859a9517e7dd2e5e4bf9f688ce41dc79d4bc491fc66066dfe2db4aae4d704", "com.sydxky.rtos.inputmethod/scripts/build-icons.py": "51f859a9517e7dd2e5e4bf9f688ce41dc79d4bc491fc66066dfe2db4aae4d704",
"com.sydxky.rtos.inputmethod/scripts/convert-dictionary.py": "83eea8c7adae0254eb69170dec905c285c09c43ffcf10e8e553beb108acf43b3", "com.sydxky.rtos.inputmethod/scripts/convert-dictionary.py": "ad747c27931a683b7132e7273e4913dc2c35e110b2c6990083425a7e42817bd9",
"com.sydxky.rtos.inputmethod/scripts/install.mjs": "46c7d95b25c312734c0ca8387f4a782becd4723f21d01492d074da22ca05432d", "com.sydxky.rtos.inputmethod/scripts/install.mjs": "ab2a23bfd457e40f56f17bcf8868482b0ed2d626629e52f788f6adcc3b4ab71f",
"com.sydxky.rtos.inputmethod/src/app.js": "e51c7e0a67563bd64b6bc04aedde22842295824432942bc8f1ab0d4731cc3ed3", "com.sydxky.rtos.inputmethod/src/app.js": "e51c7e0a67563bd64b6bc04aedde22842295824432942bc8f1ab0d4731cc3ed3",
"com.sydxky.rtos.inputmethod/src/assets/crab-keyboard-license.txt": "b140337f94481310f35abfd2210fde308ba30411899095fd961a77b94e022775", "com.sydxky.rtos.inputmethod/src/assets/crab-keyboard-license.txt": "b140337f94481310f35abfd2210fde308ba30411899095fd961a77b94e022775",
"com.sydxky.rtos.inputmethod/src/assets/icon.png": "f712ac5f8c500a33dd72cc1f15f9ad467e6043c284fb6ece1f1683e54d40e683", "com.sydxky.rtos.inputmethod/src/assets/icon.png": "f712ac5f8c500a33dd72cc1f15f9ad467e6043c284fb6ece1f1683e54d40e683",
"com.sydxky.rtos.inputmethod/src/assets/ime-icons/LICENSE.txt": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
"com.sydxky.rtos.inputmethod/src/assets/ime-icons/arrow-down-s-line.png": "69bb846769721ed4ad056ed0136da59d4aa6873edccd1e38a7ebd783cb94ed1e", "com.sydxky.rtos.inputmethod/src/assets/ime-icons/arrow-down-s-line.png": "69bb846769721ed4ad056ed0136da59d4aa6873edccd1e38a7ebd783cb94ed1e",
"com.sydxky.rtos.inputmethod/src/assets/ime-icons/arrow-left-s-line.png": "68d8920081fb883f4c59af47549d6b41a9b0a3748383b814e4480bfab0e711ed", "com.sydxky.rtos.inputmethod/src/assets/ime-icons/arrow-left-s-line.png": "68d8920081fb883f4c59af47549d6b41a9b0a3748383b814e4480bfab0e711ed",
"com.sydxky.rtos.inputmethod/src/assets/ime-icons/arrow-right-s-line.png": "c6bffa0a51254c60c92e276e4d21ae8a1595d82c77d9d6a1064afbf534ea3562", "com.sydxky.rtos.inputmethod/src/assets/ime-icons/arrow-right-s-line.png": "c6bffa0a51254c60c92e276e4d21ae8a1595d82c77d9d6a1064afbf534ea3562",
@@ -40,20 +88,24 @@
"com.sydxky.rtos.inputmethod/src/assets/ime-icons/delete-back-2-line.png": "a83002ddc7ba1f1a86b361c0c672ec63205393cae2df275a9ff2d49501fd6b9d", "com.sydxky.rtos.inputmethod/src/assets/ime-icons/delete-back-2-line.png": "a83002ddc7ba1f1a86b361c0c672ec63205393cae2df275a9ff2d49501fd6b9d",
"com.sydxky.rtos.inputmethod/src/assets/ime-icons/edit-2-line.png": "6161624226e2b62b24da7244d86564d6d0856533c2149d3b347e17013c562497", "com.sydxky.rtos.inputmethod/src/assets/ime-icons/edit-2-line.png": "6161624226e2b62b24da7244d86564d6d0856533c2149d3b347e17013c562497",
"com.sydxky.rtos.inputmethod/src/assets/ime-icons/keyboard-box-line.png": "bf4b45e2e54b4fd7e901068df1de865334f7e4616583dc5358d35d14890948af", "com.sydxky.rtos.inputmethod/src/assets/ime-icons/keyboard-box-line.png": "bf4b45e2e54b4fd7e901068df1de865334f7e4616583dc5358d35d14890948af",
"com.sydxky.rtos.inputmethod/src/assets/ime-icons/LICENSE.txt": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
"com.sydxky.rtos.inputmethod/src/assets/jieba-license.txt": "18ba0984839f85853b29fadaf992f7dba8fd0ca0fbeae34de2b8735222dc7a37", "com.sydxky.rtos.inputmethod/src/assets/jieba-license.txt": "18ba0984839f85853b29fadaf992f7dba8fd0ca0fbeae34de2b8735222dc7a37",
"com.sydxky.rtos.inputmethod/src/common/ime-engine.js": "fbfc3fa9a09914ce69da1ec0d03bed92505389c9b8bdf254d236f379bb0ecd16", "com.sydxky.rtos.inputmethod/src/common/association-seeds.json": "181aa6f3bd77cf2f1002e405cbbfd5240f3c75a560f1a79c42cca63b70f05b58",
"com.sydxky.rtos.inputmethod/src/common/ime-lifecycle.js": "93525dffabcca0620acc36defd077ab6677b1032ef67faf695f3fbf3116b8a64", "com.sydxky.rtos.inputmethod/src/common/ime-engine.js": "7b3892b79241e2fa5bc8914bfc047bf6319194c8db1d8b33ff2a63449f859b1c",
"com.sydxky.rtos.inputmethod/src/keyboard/index.ux": "8022e5cff0075975377e0c3390ee9d9a541379768dc4732a77ff0ef865306dd3", "com.sydxky.rtos.inputmethod/src/common/ime-lifecycle.js": "60937c3a0d51ee3978f12302a31a550bcbe352dfcc2c62b45bcf3b4bab55e7ad",
"com.sydxky.rtos.inputmethod/src/common/ime-theme.js": "628a77ca3ef9cf239eab9883497ccfb29652f5f70ff0e584de6cf0c17c005999",
"com.sydxky.rtos.inputmethod/src/components/CrabInput/index.ux": "a445026ae4fa8dbb151d7d776ecd8269dc24f1775acf4b71cec30b5b8429e272",
"com.sydxky.rtos.inputmethod/src/keyboard/index.ux": "27a14680bff055c4625d7858879df7cde804b6aa399c81a07ce1079cdc60140d",
"com.sydxky.rtos.inputmethod/src/main/index.ux": "f763f0dc80b9fd46e00830d6ec0de8683063f98c1edee94ee522c2605b695fca", "com.sydxky.rtos.inputmethod/src/main/index.ux": "f763f0dc80b9fd46e00830d6ec0de8683063f98c1edee94ee522c2605b695fca",
"com.sydxky.rtos.inputmethod/src/manifest.json": "1adcd2d7610ce92c1c01f9c06f597e64569ae31de2422f1022e49bc04bb804ff", "com.sydxky.rtos.inputmethod/src/manifest.json": "c855f0db1d62b2da6e3f494c24134af27ef3665997dab35cc29494122b769a92",
"com.sydxky.rtos.inputmethod/tests/client.test.mjs": "8633a789b0c5e19ea63881ccf084e4442b0ab0fbd97a9fba4317e34e5570ff81", "com.sydxky.rtos.inputmethod/tests/client.test.mjs": "197034a327872629c1662fa3a437f41c9d31a23bf74656dad8d7d31ecfa43401",
"com.sydxky.rtos.inputmethod/tests/engine.test.mjs": "4967791409f7aa418b5d2b60a61aca48bbedfe5f8fad4a51b556a4f8e7c3e5a4", "com.sydxky.rtos.inputmethod/tests/console-native.py": "9d708993e1f3071871420a3c02c10bf422e5025c3132e6a029ec8ef036e817a2",
"com.sydxky.rtos.inputmethod/tests/layout.py": "b632411cd593d369180abaeedbf036002e986e3e5e2c2f15b4e5ff157588aa78", "com.sydxky.rtos.inputmethod/tests/engine.test.mjs": "984a8cea899b8e0762773d48d56566729a9e720f402fa7b946645d0afa429e1a",
"com.sydxky.rtos.inputmethod/tests/native.py": "4b92ee16edb92ca919f6bce52974fa114dca7b6b23c83b3300f6e370a3247379", "com.sydxky.rtos.inputmethod/tests/layout.py": "9b1fc238cae2f39e5385611cb3d5d636e40941cd4566c51d1407cfb72d6dbcff",
"com.sydxky.rtos.inputmethod/tests/visual.py": "2874b3446028f7b7e54c4b1ecf594f8801c7249d67a0d17b8e7b0fdf97c8ff0f", "com.sydxky.rtos.inputmethod/tests/native.py": "07bb05ad97fe2ca06f7f4a55e57b914b025dd70c0a6ea0249c1195e05aed5fe5",
"com.sydxky.rtos.inputmethod/third_party/UPSTREAM.md": "e8311679981a318fef7142f7a2ecb2b1a7860f5fee32282729cab6af2a41d640", "com.sydxky.rtos.inputmethod/tests/sync.test.mjs": "89a65009591eb874f82aeafb425afca99c2d5336850277e1cfa2636e05345fee",
"com.sydxky.rtos.inputmethod/tests/visual.py": "cdd48d7cb4851fbcb6b96af5098405f4d02e93726cf02320faa461592b948477",
"com.sydxky.rtos.inputmethod/third_party/crabKeyboard/LICENSE": "b140337f94481310f35abfd2210fde308ba30411899095fd961a77b94e022775", "com.sydxky.rtos.inputmethod/third_party/crabKeyboard/LICENSE": "b140337f94481310f35abfd2210fde308ba30411899095fd961a77b94e022775",
"com.sydxky.rtos.inputmethod/third_party/crabKeyboard/README.md": "88c2f96be16dc5d80677745072940c8a1cbc7aab40e554de50ec11bc22339b75",
"com.sydxky.rtos.inputmethod/third_party/crabKeyboard/lookupDictV5.original.js": "915605439ebc404d808ccce6cb18abfff3981f1cdf53a78e9a743c78c4a6ddc9", "com.sydxky.rtos.inputmethod/third_party/crabKeyboard/lookupDictV5.original.js": "915605439ebc404d808ccce6cb18abfff3981f1cdf53a78e9a743c78c4a6ddc9",
"com.sydxky.rtos.inputmethod/third_party/crabKeyboard/pinyinDictV5_20260416/key0.dat": "795a5316089b67a7bffabf87080bd61f3720f9e0fd2f2866a092901f99b794e8", "com.sydxky.rtos.inputmethod/third_party/crabKeyboard/pinyinDictV5_20260416/key0.dat": "795a5316089b67a7bffabf87080bd61f3720f9e0fd2f2866a092901f99b794e8",
"com.sydxky.rtos.inputmethod/third_party/crabKeyboard/pinyinDictV5_20260416/key1.dat": "a4bcae2b6eb30084d3cf2ac64e5d9ca71eb94e8ac1f7c8fc8b38a5d478982be2", "com.sydxky.rtos.inputmethod/third_party/crabKeyboard/pinyinDictV5_20260416/key1.dat": "a4bcae2b6eb30084d3cf2ac64e5d9ca71eb94e8ac1f7c8fc8b38a5d478982be2",
@@ -96,10 +148,9 @@
"com.sydxky.rtos.inputmethod/third_party/crabKeyboard/pinyinDictV5_20260416/value7.dat": "b9f6e109ba5f1b5bca758613cb5cccfecf358915ce6415525c333b19515d6428", "com.sydxky.rtos.inputmethod/third_party/crabKeyboard/pinyinDictV5_20260416/value7.dat": "b9f6e109ba5f1b5bca758613cb5cccfecf358915ce6415525c333b19515d6428",
"com.sydxky.rtos.inputmethod/third_party/crabKeyboard/pinyinDictV5_20260416/value8.dat": "3d1622d4717680b31a6e037c88e7f046fecfe2339e521272e1d1eae0a7094606", "com.sydxky.rtos.inputmethod/third_party/crabKeyboard/pinyinDictV5_20260416/value8.dat": "3d1622d4717680b31a6e037c88e7f046fecfe2339e521272e1d1eae0a7094606",
"com.sydxky.rtos.inputmethod/third_party/crabKeyboard/pinyinDictV5_20260416/value9.dat": "1c0caab370f1f14048ab6dd2da0011bedcddfed0ed0378b5c98171b6b22caf35", "com.sydxky.rtos.inputmethod/third_party/crabKeyboard/pinyinDictV5_20260416/value9.dat": "1c0caab370f1f14048ab6dd2da0011bedcddfed0ed0378b5c98171b6b22caf35",
"com.sydxky.rtos.inputmethod/third_party/jieba/LICENSE": "18ba0984839f85853b29fadaf992f7dba8fd0ca0fbeae34de2b8735222dc7a37", "com.sydxky.rtos.inputmethod/third_party/crabKeyboard/README.md": "88c2f96be16dc5d80677745072940c8a1cbc7aab40e554de50ec11bc22339b75",
"com.sydxky.rtos.inputmethod/third_party/jieba/dict.txt": "7197c3211ddd98962b036cdf40324d1ea2bfaa12bd028e68faa70111a88e12a8", "com.sydxky.rtos.inputmethod/third_party/jieba/dict.txt": "7197c3211ddd98962b036cdf40324d1ea2bfaa12bd028e68faa70111a88e12a8",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4", "com.sydxky.rtos.inputmethod/third_party/jieba/LICENSE": "18ba0984839f85853b29fadaf992f7dba8fd0ca0fbeae34de2b8735222dc7a37",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/README.md": "6a7bee86ad1942d01be10b556dd3036e43a59d708fc94fd2f5f3e3fcfcf84b0b",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/arrow-down-s-line.iconfont.svg": "1efa1c713b710c0e9ef5eb47bec762a4b5ce21ffb454d44a3bbb118a8bec3e83", "com.sydxky.rtos.inputmethod/third_party/remix-icon/arrow-down-s-line.iconfont.svg": "1efa1c713b710c0e9ef5eb47bec762a4b5ce21ffb454d44a3bbb118a8bec3e83",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/arrow-down-s-line.upstream.svg": "4b0bf8a45d554ba97d8115ef2713010f196ad49d57eeecb8a42d4839a5ba8f9e", "com.sydxky.rtos.inputmethod/third_party/remix-icon/arrow-down-s-line.upstream.svg": "4b0bf8a45d554ba97d8115ef2713010f196ad49d57eeecb8a42d4839a5ba8f9e",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/arrow-left-s-line.iconfont.svg": "ccf02486d37383bbdc3319d9b348c5daef43693526d41aff6ad409d04e0664ef", "com.sydxky.rtos.inputmethod/third_party/remix-icon/arrow-left-s-line.iconfont.svg": "ccf02486d37383bbdc3319d9b348c5daef43693526d41aff6ad409d04e0664ef",
@@ -114,13 +165,46 @@
"com.sydxky.rtos.inputmethod/third_party/remix-icon/delete-back-2-line.upstream.svg": "d6fc7f41c0b84fe48a06685dc081780b6cd103ac9b3db96dc44e624aa0a113e8", "com.sydxky.rtos.inputmethod/third_party/remix-icon/delete-back-2-line.upstream.svg": "d6fc7f41c0b84fe48a06685dc081780b6cd103ac9b3db96dc44e624aa0a113e8",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/edit-2-line.iconfont.svg": "74d6590a22053c253c0d00d9ac5e35fb1f8c02e2243d0e3643a959eef233476f", "com.sydxky.rtos.inputmethod/third_party/remix-icon/edit-2-line.iconfont.svg": "74d6590a22053c253c0d00d9ac5e35fb1f8c02e2243d0e3643a959eef233476f",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/edit-2-line.upstream.svg": "d03a3a7b874377a18488b7b24b596b8078afbd9bced93ad791b6fb2f4866613d", "com.sydxky.rtos.inputmethod/third_party/remix-icon/edit-2-line.upstream.svg": "d03a3a7b874377a18488b7b24b596b8078afbd9bced93ad791b6fb2f4866613d",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/icons.json": "e80556881b6db1a87e5308679d55e3d2b6bed130a4421407451eae9b0e41ff5a", "com.sydxky.rtos.inputmethod/third_party/remix-icon/icons.json": "c556c01eed73d0671f93cbeaeed8d3a628395213ef0b951bd5eb7c3d1c1618db",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/keyboard-box-line.iconfont.svg": "813d79347c357a919317edf84252a505dbdc441c1144076b4e8389a8ce217de0", "com.sydxky.rtos.inputmethod/third_party/remix-icon/keyboard-box-line.iconfont.svg": "813d79347c357a919317edf84252a505dbdc441c1144076b4e8389a8ce217de0",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/keyboard-box-line.upstream.svg": "e39f28cbf418269405f517126a64cbf4b3332b3295cc8a3af670f77344a669c4", "com.sydxky.rtos.inputmethod/third_party/remix-icon/keyboard-box-line.upstream.svg": "e39f28cbf418269405f517126a64cbf4b3332b3295cc8a3af670f77344a669c4",
"docs/PUBLIC_RELEASE_1.1.0.md": "546906aa92b5aa7d3edf1dcf91b264fd80c6b837908ca441b0309397d12644e4", "com.sydxky.rtos.inputmethod/third_party/remix-icon/LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
"docs/UI_PREVIEW.png": "7853297cccbc52122e17605db677a1be723cacdd82dae36efb8fc7ec300ad1ab", "com.sydxky.rtos.inputmethod/third_party/remix-icon/README.md": "6a7bee86ad1942d01be10b556dd3036e43a59d708fc94fd2f5f3e3fcfcf84b0b",
"docs/public-validation.json": "ade9c563773b71fe32a6293eb25a4a7b96051bb91422f9fd157c7d0c45c185f0", "com.sydxky.rtos.inputmethod/third_party/UPSTREAM.md": "e8311679981a318fef7142f7a2ecb2b1a7860f5fee32282729cab6af2a41d640",
"tests/emulator-validate.py": "dfc550b5873f1d03254e8bc6f819139992a1648b9a10b168575cda8e35224f50" "com.sydxky.rtos.inputmethod/VALIDATION_1.1.0.md": "3b7346853bf6679c4efeda0568caece9ecb28dfde6347c96a67dc245a1e08ab9",
}, "com.sydxky.rtos.inputmethod/VALIDATION_1.2.0.md": "db7cf247579c3f6be73eaecf75fc9553214df8120ee25dc7d51c402a34ea89d6",
"sensitive_findings": [] "com.sydxky.rtos.inputmethod/VALIDATION_1.2.2.md": "21792c32b065505b2b81ebbbcf7127958fbbb0d791fa121b84cd4e384ac05221",
"com.sydxky.rtos.inputmethod/VALIDATION_1.2.3.md": "2311037bd6901906318dd67882e5adc5055e45086a69408f66fdf8b050f39be7",
"com.sydxky.rtos.inputmethod/VALIDATION_1.2.4.md": "6c0f278ca0ab2625bf6875fd4106e0ffe45b4a15b58a36463f96ee63517a3af9",
"docs/1.2.4/rect-bluewhite-middle.png": "4789f2dc33a26869dc5ce0df74891dec67d08bdeb342f38b8265d73270e96569",
"docs/1.2.4/rect-bluewhite-py-lower.png": "b02bc442e2f830cbfb7c5d34f2eb55e72be5b993f61acf0b6c4bf9234211aa29",
"docs/1.2.4/round-blackgreen-en-lower.png": "08e3313cd949839105eccfaddbbd2fa6d136fd09808b33e792d6f87d43dc1e06",
"docs/1.2.4/round-bluewhite-en-lower.png": "e58737ca2f3cb8c11bf164a729c472344b881a6e96d778cb80601b9d10514df1",
"docs/1.2.4/round-bluewhite-en-upper.png": "36373e8d3b8dd95b03ae621bccb607db891bd438a1ee1009678efd7ce4264ebf",
"docs/1.2.4/round-bluewhite-middle.png": "79e7f93b52e06ec37621bd2c2ef1ff808e12c37a3c501d0f190b5b7dc1d89819",
"docs/screenshots/android-candidates-16.png": "d244a3fb41300a7e8c478930307cfb3d4e781f016be53a9e93615bb79717ae73",
"docs/screenshots/android-english.png": "adbc1ede8f2103a6435aa04efe604737895365ff3f53e75fcb9511b9a1f4f551",
"docs/screenshots/android-fourteen.png": "88b06bdd1e6a6dc6f45722e01ce7930c94845062b8bf237aad5c5d086f37641c",
"docs/screenshots/android-home.png": "b6823a0bcfb995d6ac771252f31f7ed1d85da12e9b4b98b5ba07e801c5d6e41e",
"docs/screenshots/android-host.png": "06a770510a2802d2d7b74b2410003b3851a98b247f98fcd0dd2e4893610b456b",
"docs/screenshots/android-number.png": "c445219577c4288f351b8026cf4e42944a28b2692770ad942acda25148c04614",
"docs/screenshots/android-phrases.png": "144be16bc2d9252a711af0a57b57ba6cb624ac9e7900fadeb31447fa8060fe9e",
"docs/screenshots/android-qwerty.png": "17c9a50782be31225ba8360fb73923a90ac7a98b8ca656fcc2d2cdab2e368e8b",
"docs/screenshots/android-result.png": "65d50c4e6672f26e5f156fa52cadb9b4f4d0282ad71af71fcc1fdb5a738e5a24",
"docs/screenshots/android-symbols.png": "9025f60c6f24a09a5aabd20943ec3c2d47b4b172053317f7188b94bf5f0fe65b",
"docs/screenshots/rtos-rect-candidates.png": "fd2342bd5e6b05390097b6b55614db67442ff797afc8e3ae8b2999a478fab232",
"docs/screenshots/rtos-rect-en.png": "fe7e63f68d12d2f0eea41525927ec5e15f61032e208d89602c5c5371950268f0",
"docs/screenshots/rtos-rect-num.png": "1b7a7e904be5d267cb08d15076dca69acbb5922e375c588d93dcf8d5e950e3fa",
"docs/screenshots/rtos-round-14-key.png": "65749bb8197992a331efc2484ce8e425f5958e50fadb6d6c509b0749aa684585",
"docs/screenshots/rtos-round-candidates.png": "ab4f686aaece5abde52b3a7a57a3da10573eb95d56e4664ac03a4fe12267705d",
"docs/screenshots/rtos-round-en.png": "b94f17f121d165c33f84b0eaa4c1ca48b2dbf9890bd1ab77147965fc138de6c2",
"docs/screenshots/rtos-round-num.png": "d7fcf451229861fb564b45b6ada03501aed57cf9ca5c058833b9b65dbace06eb",
"docs/screenshots/rtos-round-phrase-candidates.png": "507d09901681bbeae4da39fa0a700663d89b7149f7fab01dd603630c932b4dc4",
"docs/SCREENSHOTS.md": "347e2eb2cb21aa332dbb20f8e2a8aaeb3561ffdcc7139766e92a78b0a24047a9",
"docs/UI_PREVIEW.png": "e58737ca2f3cb8c11bf164a729c472344b881a6e96d778cb80601b9d10514df1",
"LICENSE": "4f3547e23d7fd227a33aaa6a075185be8299403b03e534627bbb3bc3bdcf97af",
"NOTICE.md": "eb75c7be9055410bec57688b363be4761c2ee6f2ae4cfba4ab908d0b9ce68999",
"README.md": "a36d44e8229f94915c590c88940bbc717e71aa8efb6f6e13fae7efd023c98924",
"tests/emulator-validate.py": "2a549e3c7bc177b942826425e548f68db39a32d8a55ade080f0d8ab3e9c00060"
}
} }
+75 -27
查看文件
@@ -1,46 +1,90 @@
# 蟹蟹输入 · RTOS适配改版 # 蟹蟹输入 · RTOS / Android 手表适配改版
基于[CrabKeyboard / 蟹蟹输入](https://github.com/crabPangXie/crabKeyboard)的独立Glyphix RTOS改版,由herry适配。不是上游原版,不表示上游或设备厂商背书。 为小屏手表准备的离线输入组件:大按键、浅色界面、完整候选、圆屏安全布局。基于 [CrabKeyboard / 蟹蟹输入](https://github.com/crabPangXie/crabKeyboard) 的词库,由 herry 重写 Glyphix RTOS 与原生 Android 适配;本项目为独立改版,保留原作者及第三方署名。
**MIT开源,允许商用、修改、再分发和闭源集成。** 请保留版权及许可全文;[LICENSE](./LICENSE) / [署名与第三方许可](./NOTICE.md)。Remix Icon图标另按Apache-2.0使用,厂商SDK/运行时不包含在本项目MIT授权中。 **MIT 开源,允许商用、修改、再分发和闭源集成。** 使用时请保留 [LICENSE](LICENSE) 和 [第三方许可](NOTICE.md)。厂商 SDK、运行时与设备固件不包含在本项目授权中。
![实际模拟器界面](./docs/UI_PREVIEW.png) ![1.2.4 RTOS原生测试工程界面,验证范围见截图说明](docs/UI_PREVIEW.png)
## 功能 ## 1.2.4 光标贴合、26键扩大与大小写切换
- 浅色满屏键盘,适配圆屏466×466及方屏390×450。 - RTOS光标改为独立1像素细线,紧贴前后文字,保留真实空格、emoji与私密遮蔽;长文本仍保持光标可见。Android预览及示例编辑框去掉额外字间距。
- 26键全拼/英文均为标准QWERTY,10/9/7三排;九宫拼音与14键双字母模式。 - 扩大26键,标准10/9/7三排;圆屏逐排按安全宽度收放,方屏充分利用宽度。
- 14键采用`QW ER TY UI OP / AS DF GH JK L / ZX CV BN M`,参考公开布局,自研离线消歧,保留完整候选及连续输入。 - 点“abc”进入英文,再点“大写”切到大写,点“小写”恢复小写;按钮显示下一步动作,键面与实际输入同步。
- 104,837组拼音/缩写索引、163,238候选项,四项分页,不作100项截断。 - 保留1.2.3未完成拼音补全、上下文续词及稳定刷新:全拼/九宫/14键同步支持;完全离线,密码不参与上下文。
- 英文大小写、数字、符号、Unicode光标与删除、可选本地学习、私密输入和本机草稿。
- 原生`.ux`页面,可通过`router.push`接入其他Glyphix应用;系统级全局输入法注册需厂商支持。
## 下载与反馈 RTOS **59项引擎、49项UX及四配置生产构建通过**。独立原生测试工程中,生产键盘组件源码逐字节相同,圆/方普通及QuickApp四配置共96个状态全部通过,光标额外布局间距0px、26个按键边界合格;各配置均实际点击输入`qQq`。96张原生截图随验收ZIP提供。
- [1.1.0发行版与两屏QuickApp完整包](https://git2.sydxky.cn/herry/crab-input-rtos/releases) Android **7项Java测试及OWW261后台原生组件4项通过**,包含24帧非空绘制/控件复用、补全/续词、密码隔离、实际大小写切换和圆屏边界。Release APK/AAR、生产签名及API27元数据通过。上述是组件回归,未代替最终发布APK的前台端到端验收;本版未重跑原前台交互。RTOS截图来自明确标记的原生测试工程,Android截图为历史资料。
- [已有问题与建议](https://git2.sydxky.cn/herry/crab-input-rtos/issues)
- [免注册匿名提交问题与建议](https://git2.sydxky.cn/feedback/herry/crab-input-rtos)
Git2免登录浏览、克隆和下载,当前关闭注册,代码与版本由所有者维护。 [本版RTOS验收](com.sydxky.rtos.inputmethod/VALIDATION_1.2.4.md) · [本版Android验收](android/VALIDATION_1.2.4.md)
```powershell ## 1.2.0 已有功能
git clone https://git2.sydxky.cn/herry/crab-input-rtos.git
cd crab-input-rtos - 完整候选采用 **4×4 网格,单字 16 项一页**;词组占用更多列,长词增加行高,显示完整文字;展开页不显示拼音。
- 标准 **QWERTY 26 键**,排列为 `qwertyuiop / asdfghjkl / zxcvbnm`;同时提供九宫和 14 键双字母模式。
- 14 键为 `QW ER TY UI OP / AS DF GH JK L / ZX CV BN M`,参考公开布局,自行实现离线消歧和连续输入;[算法说明](com.sydxky.rtos.inputmethod/KEYBOARD_14.md)。
- 保留全部 **104,837 组拼音/缩写索引、163,238 个候选项**。按页浏览全部结果,支持分段选词及剩余编码继续输入。
- 英文、数字、符号、光标移动及 Unicode 删除。圆屏每行按安全宽度排布,避免四角按钮被裁切。
- 新增 **Android 应用内键盘 AAR**、演示 APK、独立宿主示例。接入后点击输入框自动弹出,由组件直接回填宿主输入框。
## 选择适合的平台
| 平台 | 接入与产物 | 已完成的验证 |
| --- | --- | --- |
| Glyphix RTOS | 原生 `.ux` 页面;圆屏 466×466、方屏 390×450 两屏 QuickApp 完整 ZIP | 普通/QuickApp四配置构建;59项引擎和49项UX;独立原生组件四配置96状态/截图通过 |
| Android 8.1 / API 27+ | 原生 AAR;演示 APK;独立 `host/` 示例 | OWW261后台原生组件4项通过;24帧非空绘制及控件复用;前台交互未重跑 |
Android 版本作为**应用内公开组件**,由每个应用自行接入;组件不注册系统输入法。RTOS 页面也需宿主接入,系统级注册依赖厂商支持。
完全离线,输入功能无需账号。Android 组件不申请网络/外部存储/悬浮窗权限,不保存输入内容;RTOS 端的可选本地学习、私密模式及草稿说明见 [隐私说明](com.sydxky.rtos.inputmethod/PRIVACY.md)。
## 下载、源码与反馈
- [v1.2.4 发行包与更新说明](https://git2.sydxky.cn/sydxky/crab-input-rtos/releases/tag/v1.2.4):AAR、演示 APK、两屏 RTOS QuickApp、原生组件验收记录/截图及 SHA-256。
- [Android 接入文档](android/README.md) · [RTOS 接入文档](com.sydxky.rtos.inputmethod/README.md)
- [实际界面截图](docs/SCREENSHOTS.md) · [RTOS 验收](com.sydxky.rtos.inputmethod/VALIDATION_1.2.0.md) · [Android 真机验收](android/VALIDATION_1.2.0.md)
- [已有问题](https://git2.sydxky.cn/sydxky/crab-input-rtos/issues) · [免注册提交问题与建议](https://git2.sydxky.cn/feedback/sydxky/crab-input-rtos)
Git2 免登录浏览、克隆和下载;当前关闭注册,代码和版本由所有者维护。
```sh
git clone https://git2.sydxky.cn/sydxky/crab-input-rtos.git
``` ```
## 构建 ## Android 开发
准备Windows PowerShell 7、Node.js和Python 3。建议克隆到较短路径(例如`F:\Project\crab-input-rtos`),厂商图像工具在过长Windows路径可能失败。厂商Glyphix SDK不随仓库分发;自行取得适用授权后,将含`bin/gx.exe`与`share/`的完整SDK放在`temp/glyphix-v0.7.2-windows-x64/`。本版验证使用`gx v0.10.1 / 3ed38835`,需要`generic-watch-466x466-quickapp`和`generic-watch-390x450-quickapp`配置。 准备 JDK 17、Android SDK compile 37、Python 3;SDK 与签名私钥不随源码分发。
```powershell
cd android
python scripts/build-dictionary.py
.\gradlew.bat :keyboard:testDebugUnitTest :keyboard:lintRelease :keyboard:assembleRelease :app:assembleDebug :host:assembleDebug
```
生成的 `keyboard/build/outputs/aar/keyboard-release.aar` 可直接放入宿主 `app/libs/`。在 Activity 的 UI 线程使用:
```java
import com.sydxky.crabinput.CrabKeyboard;
CrabKeyboard.Binding keyboard = CrabKeyboard.attach(this, editText);
// 有自己的监听器时,在相应事件中调用 keyboard.show()。
// 绑定自动跟随 Activity 生命周期,也可显式 keyboard.close()。
```
源码不包含生成的 SQLite,构建脚本从本仓库 RTOS 原始词库无损生成。正式演示 APK 签名由开发者自己的受保护配置提供;AAR 无需应用签名。
## RTOS 开发
准备 Windows PowerShell 7、Node.js、Python 3,以及合法取得的 Glyphix SDK。验证版本为 `gx v0.10.1 / 3ed38835`。将包含 `bin/gx.exe` 和 `share/` 的完整 SDK 放在 `temp/glyphix-v0.7.2-windows-x64/`:
```powershell ```powershell
& .\com.sydxky.rtos.inputmethod\build.ps1 & .\com.sydxky.rtos.inputmethod\build.ps1
``` ```
脚本无损生成词库分片,执行40项引擎及29项UX脚本断言,然后构建普通/QuickApp四配置。QuickApp输出位于应用的`.glyphix-work/dist/`,每屏必须保留完整`bundle.pkg`、`icon.png`、`manifest.json`,不是APK/HAP。 脚本生成词库分片,执行 59 项引擎、49 项 UX 断言,并构建普通/QuickApp 四配置。每屏 QuickApp 都须保留 `bundle.pkg`、`icon.png`、`manifest.json` 三个文件。
## 原生模拟器复现 复现原生验收时先构建最小参考页并启动一次圆屏模拟器,获得厂商 launcher/global 资源后退出;不要同时占用 Inspector 14201:
`com.sydxky.rtos.imefixture/`是本仓库附带的最小离线验收页,只用于实际前后台切换,不包含其他内部应用。先构建并启动一次圆屏参考页,获得厂商launcher/global资源,然后退出该模拟器:
```powershell ```powershell
& .\com.sydxky.rtos.imefixture\build.ps1 & .\com.sydxky.rtos.imefixture\build.ps1
@@ -51,6 +95,10 @@ python .\com.sydxky.rtos.inputmethod\tests\native.py --work work/native --output
python .\com.sydxky.rtos.inputmethod\tests\visual.py --work work/visual --output work/evidence/visual python .\com.sydxky.rtos.inputmethod\tests\visual.py --work work/visual --output work/evidence/visual
``` ```
视觉脚本需要Pillow。运行时保持8192 KiB内存,不接管其他占用Inspector 14201的进程。1.1.0原产品四配置84项原生操作、84项操作后布局、44个视觉状态均通过,见[验收记录](./com.sydxky.rtos.inputmethod/VALIDATION_1.1.0.md)。公开仓库使用最小参考页,已重新完成独立源码四配置构建、84/84原生操作、84/84布局和44/44视觉状态,见[公开树验收](./docs/PUBLIC_RELEASE_1.1.0.md)。 本版光标和26键回归可在独立原生模拟器中执行,不启用Inspector:
真机安装、目标固件性能/续航和商店审核尚未验收。详细接入、词库与14键说明见[应用文档](./com.sydxky.rtos.inputmethod/README.md)和[14键算法](./com.sydxky.rtos.inputmethod/KEYBOARD_14.md)。 ```powershell
python .\com.sydxky.rtos.inputmethod\tests\console-native.py --work work/caret --output work/evidence/caret
```
视觉脚本依赖 Pillow。RTOS 运行时内存配置保持 8192 KiB。本版本已完成上述算法、组件回归与构建检查;未完成的整包前台交互、RTOS真机、长期性能/续航和商店审核范围见本版验收记录。
+13
查看文件
@@ -0,0 +1,13 @@
.gradle/
local.properties
build/
app/build/
keyboard/src/main/assets/dictionary.db
artifacts/
release-signing.properties
*.jks
*.keystore
**/__pycache__/
keyboard/build/
host/build/
+23
查看文件
@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2026 sydxky
Copyright (c) 2026 kqakqakqa
Copyright (c) 2026 crabKun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
查看文件
@@ -0,0 +1,17 @@
# 蟹蟹输入 Android手表适配改版
本项目由sydxky基于[CrabKeyboard / 蟹蟹输入](https://github.com/crabPangXie/crabKeyboard)的MIT词库重新实现Android应用内键盘组件、圆屏布局、九宫/14键消歧及候选分页。不是上游原版,也不表示上游作者或设备厂商对本改版作出背书。
上游固定提交:`93d36b5002dee80a3d253b1ec7ec9e29800a104c`。保留原作者kqakqakqa、crabKun的版权声明和MIT许可证;原始词库位于同仓库RTOS目录。Android自研适配部分以[MIT许可证](./LICENSE)发布,**允许商用、修改、再分发和闭源集成**,须保留适用版权及许可全文。
第三方内容继续按各自许可证使用:
| 内容 | 作者/项目 | 许可与位置 |
| --- | --- | --- |
| CrabKeyboard V5词库 | kqakqakqa、crabKun | MIT,`keyboard/src/main/assets/licenses/crabKeyboard-LICENSE.txt` |
| 本地候选排序词频 | jieba 0.42.1 / Sun Junyi | MIT,`keyboard/src/main/assets/licenses/jieba-LICENSE.txt` |
| Gradle wrapper | Gradle | Apache-2.0,`keyboard/src/main/assets/licenses/Gradle-LICENSE.txt` |
14键参考公开演示中的相邻字母分组,自行实现离线消歧;不包含华为内部模型或固件代码。Android SDK、JDK和构建工具不随源码分发,按各自许可使用。AndroidX及JUnit仅用于本地测试,不打入正式APK。
公开仓库:<https://git2.sydxky.cn/sydxky/crab-input-rtos>。匿名问题与建议:<https://git2.sydxky.cn/feedback/sydxky/crab-input-rtos>。
+64
查看文件
@@ -0,0 +1,64 @@
# 蟹蟹输入 Android应用内键盘组件
基于CrabKeyboard MIT词库,由herry重写的Android手表原生键盘组件,1.2.4 / 10204。提供可集成的 **AAR**、演示APK和独立接入示例;适用Android 8.1 / API 27及以上。
应用接入后,点击自己的输入框自动展开内置键盘。**不注册系统输入法、不要求用户切换输入法、不改变系统默认输入法。** 圆屏按每行所在高度计算安全弦宽;4×4完整候选,单字16个一页,词组扩宽或增加行高,候选不显示拼音。九宫、标准QWERTY全拼/英文、14键、数字、三页符号、Unicode删除、按住连删及光标编辑。
完全离线,无网络或外部存储权限;组件不持久化输入内容,不启用学习。密码输入默认英文并遮蔽预览。页面隐藏/销毁时清除组件会话与候选,宿主输入框中的内容仍由宿主决定如何处理。异步查询按会话序号隔离,旧结果不能回填新的输入框。
1.2.4 去掉预览与示例编辑框的额外字间距,扩大26键;每排继续按实际屏幕弦宽计算安全边界。点“abc”进入英文,随后“大写/小写”按钮显示下一步动作,字母标签与实际输入保持一致。见[本版验收](./VALIDATION_1.2.4.md)。
1.2.3 增加未完成拼音补全、上下文续写及词频排序;复用预览、按键及候选控件,按帧合并刷新。联想仅在内存中使用当前输入框光标前的中文,不持久化全文,密码内容不参与。见[本版验收](./VALIDATION_1.2.3.md)。
1.2.2 去掉英文、数字、符号模式未使用的候选栏空位,文字与键盘紧密相接。本版真机间距检查通过;完整测试的限制见[验收记录](./VALIDATION_1.2.2.md)。
## 一分钟接入
将发行版`crab-keyboard-1.2.4.aar`放入宿主`app/libs/`:
```groovy
dependencies { implementation files('libs/crab-keyboard-1.2.4.aar') }
```
```java
import com.sydxky.crabinput.CrabKeyboard;
// Activity 的 UI 线程,已有 EditText;attach 返回一个可关闭绑定。
CrabKeyboard.Binding keyboard = CrabKeyboard.attach(this, editText);
// 已有自己的焦点/点击处理时,可在相应事件调用 keyboard.show()。
// Activity 销毁时可显式 keyboard.close(),重复关闭安全。
```
`attach`会接管该输入框的焦点和点击监听器,并关闭此框的系统软键盘自动显示。`show()`、`dismiss()`、`close()`在UI线程调用。自动监听宿主Activity停止和销毁,停止时关闭会话,关闭绑定时恢复输入框原来的软键盘显示属性。组件通过该输入框的Editable/InputConnection回填,支持选区和数字/密码/网址InputType。窗口为宿主应用自己的Dialog,不申请悬浮窗权限。
需要保留自定义监听器时,先取得绑定,再在自定义事件中调用`keyboard.show()`;不要重复绑定同一个输入框。`host/`是独立包名的最小接入示例,`app/`是完整演示。未接入的应用继续使用原有系统输入法。
## 模块与构建
| 模块 | 用途 |
| --- | --- |
| `keyboard/` | 原生公开组件、词库、MIT许可,生成AAR |
| `app/` | 圆屏演示APK,包名`com.sydxky.crabinput` |
| `host/` | 独立应用集成示例,证明组件可由其他应用接入 |
准备JDK 17、Android SDK compile 37及Python 3。词库位于同仓库RTOS目录,SDK/私钥不随源码分发。
```powershell
python scripts/build-dictionary.py
.\gradlew.bat :keyboard:testDebugUnitTest :keyboard:lintRelease :keyboard:assembleRelease :app:lintRelease :app:assembleDebug :host:assembleDebug
# 演示APK正式签名由本机受保护文件提供;组件AAR无须应用签名。
.\gradlew.bat :app:assembleRelease -PreleaseSigningProperties=<本机签名配置路径>
```
输出`keyboard/build/outputs/aar/keyboard-release.aar`。SQLite词库无损转换并全量回读104,837组索引、163,238候选;数据库是可生成的本地资产,不进入Git。正式APK必须使用已批准签名,签名材料不公开。
## 真机验证
短时验收在已连接的OWW261上使用真实EditText和公开组件,通过UiAutomator点击操作,不直接调用键盘内部状态代替交互;比较测试前后系统默认输入法保持一致。范围及截图见[1.2.0验收](./VALIDATION_1.2.0.md)。演示应用内即可测试,无需启用或切换系统输入法。
```powershell
.\gradlew.bat :app:assembleAndroidTest :host:assembleDebug -PimeTestBuildType=release -PreleaseSigningProperties=<本机签名配置路径>
python scripts/validate-device.py --serial <已连接手表序列号> --output <本地证据目录>
```
源码、截图及下载:<https://git2.sydxky.cn/sydxky/crab-input-rtos>。MIT允许商用,保留[LICENSE](./LICENSE)、[NOTICE](./NOTICE.md)及词库/jieba许可。AndroidX/JUnit仅用于测试;AAR无第三方运行时依赖。当前是组件和演示验收,未进行商店审核。
+28
查看文件
@@ -0,0 +1,28 @@
# Android 应用内键盘 1.2.0 验收
日期:2026-11-14。OWW261 真机,Android 11 / API 30,466×466。正式演示 APK 包名 `com.sydxky.crabinput`,版本 1.2.0 / 10200,最低系统 API 27。
该版本提供 `keyboard` 原生 AAR,以及演示 APK、独立包名接入示例。组件通过宿主 Dialog 展开、直接回填 EditText,不包含系统输入法 Service。测试前后系统默认输入法一致。
| 真实交互项目 | 结果 |
| --- | --- |
| 完整词库在全拼、九宫、14 键中查询 | 通过 |
| 密码及数字输入框选择安全模式 | 通过 |
| Unicode 码点删除、按住连删及松手停止 | 通过 |
| 独立应用接入公开组件并回填 | 通过 |
| 14 键歧义及词组占宽显示 | 通过 |
| 全拼连续输入“你好世界” | 通过 |
| 九宫词组及删除回填 | 通过 |
| 单字 4×4、全部候选翻页、末页选词及圆屏安全边界 | 通过 |
最终验收 8/8,通过实际 UiAutomator 按钮操作;没有通过调用键盘内部状态代替交互。候选查询完成时控件树会更新,测试仅在注入点击前遇到失效节点时重新查找,行为断言保持严格。截图为最终正式 APK 的实际设备画面。演示首页显式设置输入框内边距及背景,消除厂商默认 EditText 内边距导致的文字裁切。
最终演示 APK SHA-256:
```text
4e2b0ce4a9d82ae6994173cd17472ee4e711a49bc33c5c8e6c202c2fd4857271
```
组件 4 项 Java 单元测试、组件/演示应用 release lint、AAR 和签名 APK 构建通过。SQLite `integrity_check`、全部 104,837 组索引/163,238 候选回读通过。AAR 无第三方运行时依赖,不申请网络、外部存储或悬浮窗权限。
本次为短时交互验收,未进行长期压测、功耗或商店审核。系统默认输入法始终由用户原有设置决定,组件只在已接入它的应用中使用。
+13
查看文件
@@ -0,0 +1,13 @@
# Android 输入组件 1.2.2 间距修复
2026-11-14,演示包 `com.sydxky.crabinput`,版本 `1.2.2 / 10202`;最低API27。
英文、数字、符号模式现在由编码标题直接定位首排按键,不再先分配候选区域再局部抵消高度,去掉12逻辑像素的空位。中文候选栏与九宫、全拼、14键行为保持原逻辑。
4项Java单元测试通过;组件及演示Release构建和lint通过(组件无问题,演示0错误/1警告)。全量词库104,837组索引、163,238候选无损回读与SQLite完整性检查通过。正式演示APK为原生产证书、单签名者、APK v2有效,AAR不需要应用签名。
实体OWW261,Android11/API30、466×466。UiAutomator新增的 `directModesHaveNoUnusedCandidateGap` 已通过:实际打开内置键盘,分别点击英文、数字、符号模式,测得编码标题到首排按键间距在0–5物理像素内,预览到编码标题在0–4像素内;同场检查全部可见按钮的圆屏安全边界,并保留三张真机截图。
整组9项测试为6项通过、3项未通过。通过项包括新间距检查、全量词库、安全输入模式、14键词组、全拼连续输入、完整候选分页。另三项(Unicode/长按删除、独立宿主接入、九宫词组)执行期间,同一手表被另一任务切换到弹球界面,造成找不到目标输入框;未将它们记为通过。按用户明确选择“仅完成现有验证并发布”,停止争用设备且未重跑。测试前后系统默认输入法一致。
本次没有领取账号奖励、执行支付或修改其他应用数据。RTOS真机、另一款方表、长期性能/功耗和商店审核未验收。
+9
查看文件
@@ -0,0 +1,9 @@
# Android 1.2.3 联想和刷新验收
2026-11-14,版本1.2.3/10203,最低API27。原始词库无损回读104,837组索引、163,238候选,SQLite schema2新增28,977个续写上下文/52,443候选对,完整性检查通过。新缓存文件名与旧schema隔离,避免升级后误用旧数据库。
7项Java单元测试通过。正式Release APK/AAR、宿主与测试APK构建通过;签名及最终哈希随发行清单。lint均为0错误,演示应用1项备份声明警告,组件2项仅测试依赖版本更新提示。Android复用预览、按键和候选控件,按Choreographer帧合并刷新;提交时合并编辑,候选替换后重置页码。联想只使用当前光标前最多4个连续汉字,不保存正文,密码不参与。
OWW261 Android11/API30上运行3项后台原生组件测试通过:24帧真实原生measure/layout/draw均绘出文字,预览/按键实例保持一致;niha补全你好并接续世界且仅提交一次;密码内容不作为联想上下文。测试在离屏原生View上执行,没有打开Activity、切换前台或修改默认系统输入法;这是组件回归,不是全套前台手势/视觉验收。测试使用组件debug变体,15个运行时class成员与发布AAR逐字节一致;不以此替代发布APK端到端验收。
本版未重跑原9项前台交互,RTOS真机、另一款方表、长时性能/功耗与商店审核未验收。之前1.2.2的6/9结果及截图仅保留作历史资料,不作为本版通过项。
+9
查看文件
@@ -0,0 +1,9 @@
# Android 1.2.4 光标、26键及大小写验收
2026-11-14,版本1.2.4/10204,最低API27。预览及演示/独立宿主编辑框显式关闭额外字间距,原生EditText负责文本与光标定位。26键扩大高度,各排仍按实际圆屏弦宽分配安全区域。英文按钮显示下一步“大写/小写”,键面与实际输入同步。保留1.2.3离线补全、续词、控件复用和合并同帧刷新,密码不提供联想上下文。
7项Java测试通过。Release APK/AAR、宿主及组件测试APK构建通过;SQLite全量回读104,837组索引、163,238候选和完整性检查通过。lint 0错误:演示应用1项备份声明警告,组件2项测试依赖版本提示。最终APK为1.2.4/10204、最低API27、zipalign通过,原生产证书1名签名者,APK v2签名有效。证书SHA-256为b4632fff23289fdfa2b4a644b4e9d8fc18f7b0a3ed49fae1353ea5690cc3961b;最终APK SHA-256为37a0a80fd61ff7079a5d21ffaf016f7776de5a2df8933f1221ad8beae2ef4f6f。
OWW261 Android11/API30上4项后台原生组件回归通过:24帧measure/layout/draw持续绘出文字并复用预览和按键;niha补全你好、接续世界且仅提交一次;密码上下文隔离;实际点击大写/小写按钮及字母键输入qQq,并检查屏幕内的所有控件与圆屏按钮四角边界。
测试在离屏原生View执行,未打开Activity、切换前台或修改默认系统输入法。使用debug组件,15个运行时class成员与发布AAR逐字节相同。这是组件回归,不是发布APK前台端到端视觉验收;最终APK没有安装到共享手表,原9项前台交互未重跑。本版没有Android前台截图,历史1.2.2/1.2.0截图仅作历史资料。另一款方表、长期性能/功耗与商店审核未验收。
+60
查看文件
@@ -0,0 +1,60 @@
import java.util.Properties
plugins { id 'com.android.application' }
def signingPath = providers.gradleProperty('releaseSigningProperties').orElse('release-signing.properties').get()
def signingFile = rootProject.file(signingPath)
def signing = new Properties()
if (signingFile.isFile()) signingFile.withInputStream { signing.load(it) }
def approved = signing.getProperty('releaseApproved','false').toBoolean()
android {
namespace 'com.sydxky.crabinput'
compileSdk 37
defaultConfig {
applicationId 'com.sydxky.crabinput'
minSdk 27
targetSdk 35
versionCode 10204
versionName '1.2.4'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
signingConfigs {
if (approved) {
release {
storeFile new File(signingFile.parentFile, signing.getProperty('storeFile',''))
storePassword signing.getProperty('storePassword','')
keyAlias signing.getProperty('keyAlias','')
keyPassword signing.getProperty('keyPassword','')
enableV1Signing false
enableV2Signing true
enableV3Signing false
enableV4Signing false
}
}
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
if (approved) signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
testProguardFiles 'proguard-test.pro'
}
}
compileOptions { sourceCompatibility JavaVersion.VERSION_11; targetCompatibility JavaVersion.VERSION_11 }
lint { abortOnError true }
testBuildType providers.gradleProperty('imeTestBuildType').orElse('debug').get()
}
tasks.register('checkReleaseSigning') {
doLast {
if (!approved || !signingFile.isFile() || !android.signingConfigs.release.storeFile.isFile())
throw new GradleException('Supply approved local signing with -PreleaseSigningProperties=<protected-file>')
}
}
tasks.matching { it.name in ['packageRelease','assembleRelease'] }.configureEach { dependsOn 'checkReleaseSigning' }
dependencies {
implementation project(':keyboard')
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
androidTestImplementation 'com.google.errorprone:error_prone_annotations:2.36.0'
androidTestImplementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
}
+2
查看文件
@@ -0,0 +1,2 @@
-keep class com.sydxky.crabinput.Lexicon { public *; }
-keep class com.sydxky.crabinput.Candidate { public *; }
+7
查看文件
@@ -0,0 +1,7 @@
# error_prone annotation signatures refer to javac's Modifier enum. These are
# compile-time metadata in the test runner, never Android executable paths.
-dontwarn javax.lang.model.element.Modifier
# Keep the instrumented runner intact; production R8 rules remain independent.
-dontshrink
-dontobfuscate
-dontoptimize
@@ -0,0 +1,136 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.content.Intent;
import android.graphics.Rect;
import android.os.SystemClock;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.By;
import androidx.test.uiautomator.UiDevice;
import androidx.test.uiautomator.UiObject2;
import androidx.test.uiautomator.UiScrollable;
import androidx.test.uiautomator.UiSelector;
import androidx.test.uiautomator.Until;
import org.junit.Before;
import org.junit.Test;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.Assert.*;
/** Uses the public embedded AAR and real editors, never calls page methods. */
public class ImeDeviceTest {
private UiDevice device;
private String keyboardPackage;
private static final String PKG="com.sydxky.crabinput";
@Before public void launch() throws Exception {
keyboardPackage=PKG;device=UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());device.wakeUp();
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();
Intent intent=new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_ACTIVITY_CLEAR_TASK);context.startActivity(intent);
assertTrue(device.wait(Until.hasObject(By.text("蟹蟹输入")),5000));
}
private void editor(String description) throws Exception {
UiObject2 field=device.findObject(By.desc(description));
if(field==null){new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().description(description));field=device.findObject(By.desc(description));}
assertNotNull(field);field.click();assertTrue("Embedded keyboard auto-opens",device.wait(Until.hasObject(By.desc("蟹蟹输入键盘")),5000));SystemClock.sleep(500);
}
private void tap(String text){clickFresh(By.pkg(keyboardPackage).text(text),text);SystemClock.sleep(95);}
private void action(String description){clickFresh(By.pkg(keyboardPackage).desc(description),description);SystemClock.sleep(150);}
private void clickFresh(androidx.test.uiautomator.BySelector selector,String label){
// A dictionary result may rebuild the view tree between lookup and tap.
// Retry only a stale node detected BEFORE click injection, never a
// failed behavioral assertion or an already-injected user operation.
for(int attempt=0;attempt<3;attempt++){
UiObject2 item=device.wait(Until.findObject(selector),5000);assertNotNull("Missing button "+label,item);
try{item.click();return;}catch(androidx.test.uiautomator.StaleObjectException error){if(attempt==2)throw error;SystemClock.sleep(150);}
}
}
private void type(String text){for(int i=0;i<text.length();i++)tap(text.substring(i,i+1));}
private List<UiObject2> singles(){List<UiObject2> result=new ArrayList<>();for(UiObject2 button:device.findObjects(By.pkg(PKG).clazz("android.widget.Button"))){String text=button.getText();if(text!=null&&text.length()==1&&Character.UnicodeScript.of(text.charAt(0))==Character.UnicodeScript.HAN)result.add(button);}return result;}
@Test public void fullGridAndEveryPageAreUsable() throws Exception {
editor("demo_text");tap("26键");type("shi");action("更多候选");
assertFalse("No reading is rendered on expanded pages",device.hasObject(By.pkg(PKG).text("shi")));
List<UiObject2> cells=singles();assertEquals(16,cells.size());Set<Integer> rows=new HashSet<>();
for(UiObject2 cell:cells){Rect r=cell.getVisibleBounds();rows.add(r.centerY());assertTrue(r.width()>35&&r.height()>30);assertRoundSafe(r);}
assertEquals(4,rows.size());
UiObject2 counter=device.findObject(By.pkg(PKG).text(java.util.regex.Pattern.compile("[0-9]+ 项.*[0-9]+ / [0-9]+")));
assertNotNull(counter);int pageCount=Integer.parseInt(counter.getText().substring(counter.getText().lastIndexOf('/')+1).trim());
Set<String> words=new HashSet<>();String last="";
for(int page=0;page<pageCount;page++){
for(UiObject2 cell:device.findObjects(By.pkg(PKG).clazz("android.widget.Button"))){String text=cell.getText();if(text!=null&&!text.isEmpty()&&!text.equals("键盘")&&!text.equals("编辑")&&Character.UnicodeScript.of(text.codePointAt(0))==Character.UnicodeScript.HAN){words.add(text);last=text;}}
if(page<pageCount-1)action("下一页");
}
assertTrue("Complete dictionary remains reachable",words.size()>=80);tap(last);action("收起键盘");
UiObject2 field=device.findObject(By.desc("demo_text"));assertNotNull(field);assertEquals(last,field.getText());
}
private void assertRoundSafe(Rect r){
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();if(!context.getResources().getConfiguration().isScreenRound())return;
float cx=device.getDisplayWidth()/2f,cy=device.getDisplayHeight()/2f,limit=Math.min(cx,cy)-3;
for(int x:new int[]{r.left,r.right})for(int y:new int[]{r.top,r.bottom})assertTrue("Button stays inside round screen: "+r,Math.hypot(x-cx,y-cy)<=limit);
}
@Test public void fullPinyinContinuousInputCommitsIntoEditor() throws Exception {
editor("demo_text");tap("26键");type("nihaoshijie");tap("你好");tap("世界");action("完成");
assertEquals("你好世界",device.findObject(By.desc("demo_text")).getText());
}
@Test public void fourteenKeysAndWidePhraseCandidates() throws Exception {
editor("demo_text");tap("14键");for(String key:new String[]{"BN","UI","GH","AS","OP"})tap(key);
assertNotNull(device.wait(Until.findObject(By.pkg(PKG).text("你好")),5000));action("更多候选");
List<UiObject2> phrases=device.findObjects(By.pkg(PKG).clazz("android.widget.Button"));boolean found=false;
for(UiObject2 button:phrases)if("你好".equals(button.getText())){assertTrue(button.getVisibleBounds().width()>device.getDisplayWidth()/3);found=true;}
assertTrue(found);tap("你好");action("完成");assertEquals("你好",device.findObject(By.desc("demo_text")).getText());
}
@Test public void t9PhraseAndUnicodeDelete() throws Exception {
editor("demo_text");for(String key:new String[]{"MNO","GHI","GHI","ABC","MNO"})tap(key);tap("你好");
action("删除");action("收起键盘");assertEquals("你",device.findObject(By.desc("demo_text")).getText());
}
@Test public void passwordAndNumericEditorsChooseSafeModes() throws Exception {
editor("demo_password");assertNotNull(device.findObject(By.pkg(PKG).text("私密输入")));type("secret");
assertFalse(device.hasObject(By.pkg(PKG).text("secret")));action("收起键盘");
editor("demo_number");type("1230");action("完成");assertEquals("1230",device.findObject(By.desc("demo_number")).getText());
}
@Test public void allOriginalDictionaryWordsRemainQueryable() throws Exception {
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();
try(Lexicon lexicon=new Lexicon(context)){
for(String[] word:new String[][]{{"nihao","你好"},{"shijie","世界"},{"zhongguo","中国"},{"shurufa","输入法"},{"beijing","北京"}})for(String mode:new String[]{"py","t9","14"}){
List<Candidate> result=lexicon.lookup(mode.equals("py")?word[0]:Lexicon.encode(word[0],mode),mode);
assertTrue(word[1]+" in "+mode,result.stream().anyMatch(item->item.word.equals(word[1])));
}
assertTrue(lexicon.lookup("shi","py").size()>=90);
}
}
@Test public void anotherApplicationEmbedsThePublicComponent() throws Exception {
keyboardPackage=PKG+".host";Intent fixture=new Intent().setComponent(new android.content.ComponentName(PKG+".host",PKG+".host.HostActivity")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
InstrumentationRegistry.getInstrumentation().getTargetContext().startActivity(fixture);
UiObject2 editor=device.wait(Until.findObject(By.desc("fixture_editor")),5000);assertNotNull(editor);editor.click();
assertTrue(device.wait(Until.hasObject(By.desc("蟹蟹输入键盘")),5000));tap("26键");type("nihao");tap("你好");action("收起键盘");
assertEquals("你好",device.findObject(By.desc("fixture_editor")).getText());
}
@Test public void unicodeAndHeldDeleteRespectTheRealEditor() throws Exception {
editor("demo_text");action("收起键盘");UiObject2 field=device.findObject(By.desc("demo_text"));field.setText("你😀");field.click();action("删除");action("收起键盘");
assertEquals("你",device.findObject(By.desc("demo_text")).getText());
field=device.findObject(By.desc("demo_text"));field.setText("abcdefghijklmnop");field.click();
UiObject2 delete=device.wait(Until.findObject(By.desc("删除")),5000);Rect bounds=delete.getVisibleBounds();device.swipe(bounds.centerX(),bounds.centerY(),bounds.centerX(),bounds.centerY(),160);SystemClock.sleep(150);action("收起键盘");
int count=device.findObject(By.desc("demo_text")).getText().length();assertTrue("Hold repeats deletion",count<14);SystemClock.sleep(300);assertEquals("Release stops repeat",count,device.findObject(By.desc("demo_text")).getText().length());
}
@Test public void directModesHaveNoUnusedCandidateGap() throws Exception {
editor("demo_text");
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();
String[][] modes={{"abc","英文","q","english"},{"123","数字","1","number"},{"符号","符号 · 1/3 · 再点符号翻页",",","symbols"}};
for(String[] mode:modes){
tap(mode[0]);
UiObject2 caption=device.findObject(By.pkg(PKG).text(mode[1]));
UiObject2 first=device.findObject(By.pkg(PKG).text(mode[2]));
UiObject2 preview=device.findObject(By.pkg(PKG).desc("编辑光标"));
assertNotNull(caption);assertNotNull(first);assertNotNull(preview);
Rect captionBounds=caption.getVisibleBounds(),keyBounds=first.getVisibleBounds();
int gap=keyBounds.top-captionBounds.bottom;
assertTrue("No blank candidate band: "+mode[3]+" gap="+gap,gap>=0&&gap<=Math.ceil(device.getDisplayHeight()*4/410f));
int previewGap=captionBounds.top-preview.getVisibleBounds().bottom;
assertTrue("Preview joins composition",previewGap>=0&&previewGap<=Math.ceil(device.getDisplayHeight()*3/410f));
for(UiObject2 button:device.findObjects(By.pkg(PKG).clazz("android.widget.Button")))assertRoundSafe(button.getVisibleBounds());
assertTrue(device.takeScreenshot(new java.io.File(context.getExternalFilesDir(null),"gap-"+mode[3]+".png")));
}
action("收起键盘");
}
}
+10
查看文件
@@ -0,0 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.type.watch" />
<application android:label="蟹蟹输入" android:icon="@drawable/ic_launcher" android:theme="@style/AppTheme"
android:allowBackup="false" android:supportsRtl="true" android:usesCleartextTraffic="false">
<activity android:name=".MainActivity" android:exported="true" android:taskAffinity="com.sydxky.crabinput" android:windowSoftInputMode="adjustResize">
<intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter>
</activity>
<meta-data android:name="com.google.android.wearable.standalone" android:value="true" />
</application>
</manifest>
@@ -0,0 +1,55 @@
package com.sydxky.crabinput;
import android.app.Activity;
import android.graphics.Color;
import android.os.Bundle;
import android.text.InputType;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
/** Public component demo: no system IME registration or setting changes. */
public final class MainActivity extends Activity {
private final List<CrabKeyboard.Binding> bindings=new ArrayList<>();
private float scale;
@android.annotation.SuppressLint("WearPasswordInput") // Local masking demo, never an account password prompt.
@Override public void onCreate(Bundle saved){
super.onCreate(saved);scale=getResources().getDisplayMetrics().widthPixels/410f;
boolean round=getResources().getConfiguration().isScreenRound();
if(round){getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN|View.SYSTEM_UI_FLAG_HIDE_NAVIGATION|View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);}
getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
ScrollView scroll=new ScrollView(this);scroll.setFillViewport(true);scroll.setBackgroundColor(0xffF2F3F6);
LinearLayout body=new LinearLayout(this);body.setOrientation(LinearLayout.VERTICAL);int side=px(round?49:22);body.setPadding(side,px(round?35:26),side,px(round?38:24));scroll.addView(body);
label(body,"蟹蟹输入",27,0xff20232B,Gravity.CENTER);
label(body,"应用内键盘组件",13,0xff8B919D,Gravity.CENTER);
TextView hint=label(body,"轻点输入,自动展开\n无需更换系统输入法",14,0xff2867D9,Gravity.CENTER);hint.setPadding(0,px(12),0,px(10));
field(body,"点击这里试写",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_FLAG_MULTI_LINE,"demo_text",1);
field(body,"私密输入",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_PASSWORD,"demo_password",2);
field(body,"数字输入",InputType.TYPE_CLASS_NUMBER|InputType.TYPE_NUMBER_FLAG_DECIMAL,"demo_number",3);
field(body,"网址输入",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_URI,"demo_url",4);
Button license=new Button(this);license.setAllCaps(false);license.setText("开源与接入说明");license.setTextSize(TypedValue.COMPLEX_UNIT_PX,px(14));license.setTextColor(0xff727987);license.setBackgroundColor(Color.TRANSPARENT);
license.setOnClickListener(view->new android.app.AlertDialog.Builder(this).setTitle("蟹蟹输入适配改版").setMessage("MIT允许商用。基于CrabKeyboard词库,由herry重写RTOS及Android应用内适配。原作者kqakqakqa、crabKun;词频jieba。\n\n键盘仅由接入它的应用使用,不更改系统默认输入法。不申请网络权限,不保存输入内容。\n\n开发者接入与源码:git2.sydxky.cn/sydxky/crab-input-rtos").setPositiveButton("知道了",null).show());body.addView(license);
body.setFocusableInTouchMode(true);body.requestFocus();setContentView(scroll);
}
private TextView label(LinearLayout body,String text,int size,int color,int gravity){TextView view=new TextView(this);view.setText(text);view.setTextSize(TypedValue.COMPLEX_UNIT_PX,px(size));view.setTextColor(color);view.setGravity(gravity);body.addView(view);return view;}
private void field(LinearLayout body,String hint,int type,String name,int id){
EditText editor=new EditText(this);editor.setId(id);editor.setSaveEnabled(false);editor.setHint(hint);editor.setContentDescription(name);editor.setInputType(type);editor.setTextSize(TypedValue.COMPLEX_UNIT_PX,px(19));editor.setTextColor(0xff20232B);editor.setImeOptions(EditorInfo.IME_ACTION_DONE);editor.setMinHeight(px(44));
// Vendor EditText backgrounds add large vertical insets on watches.
// Explicit padding keeps both empty hints and entered text fully visible.
editor.setPadding(px(12),0,px(12),0);editor.setGravity(Gravity.CENTER_VERTICAL);
editor.setIncludeFontPadding(false);editor.setLetterSpacing(0f);editor.setSingleLine(true);
android.graphics.drawable.GradientDrawable background=new android.graphics.drawable.GradientDrawable();background.setColor(Color.WHITE);background.setCornerRadius(px(12));editor.setBackground(background);
LinearLayout.LayoutParams layout=new LinearLayout.LayoutParams(-1,px(47));layout.topMargin=px(7);body.addView(editor,layout);bindings.add(CrabKeyboard.attach(this,editor));
}
private int px(int value){return Math.round(value*scale);}
@Override protected void onDestroy(){for(CrabKeyboard.Binding binding:bindings)binding.close();bindings.clear();super.onDestroy();}
}
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="96dp" android:height="96dp" android:viewportWidth="96" android:viewportHeight="96">
<path android:fillColor="#2867D9" android:pathData="M20,6L76,6Q90,6 90,20L90,76Q90,90 76,90L20,90Q6,90 6,76L6,20Q6,6 20,6" />
<path android:fillColor="#FFFFFF" android:pathData="M25,28L71,28L71,68L25,68ZM30,34L30,41L37,41L37,34ZM44,34L44,41L51,41L51,34ZM58,34L58,41L65,41L65,34ZM30,47L30,54L37,54L37,47ZM44,47L44,54L51,54L51,47ZM58,47L58,54L65,54L65,47ZM35,60L35,63L61,63L61,60Z" android:fillType="evenOdd" />
</vector>
@@ -0,0 +1,8 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item><item name="android:colorAccent">#2867D9</item>
<item name="android:windowLightStatusBar">true</item><item name="android:statusBarColor">#F2F3F6</item>
<item name="android:navigationBarColor">#ECEEF2</item><item name="android:windowActionModeOverlay">true</item>
<item name="android:windowBackground">#F2F3F6</item>
</style>
</resources>
+4
查看文件
@@ -0,0 +1,4 @@
plugins {
id 'com.android.application' version '9.2.1' apply false
id 'com.android.library' version '9.2.1' apply false
}
+11
查看文件
@@ -0,0 +1,11 @@
param([string]$ReleaseSigningProperties)
$ErrorActionPreference='Stop'
Push-Location $PSScriptRoot
try {
& python '.\scripts\build-dictionary.py'
if($LASTEXITCODE -ne 0){throw 'Dictionary build failed'}
$tasks=@(':keyboard:testDebugUnitTest',':keyboard:lintRelease',':keyboard:assembleRelease',':app:lintRelease',':host:assembleDebug')
if($ReleaseSigningProperties){$tasks+=@(':app:assembleRelease',"-PreleaseSigningProperties=$ReleaseSigningProperties")}else{$tasks+=':app:assembleDebug'}
& '.\gradlew.bat' @tasks
if($LASTEXITCODE -ne 0){throw 'Android verification/build failed'}
} finally {Pop-Location}
+5
查看文件
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
# Production source is Java. Keep Kotlin needed by the test runner in test APKs.
kotlin.stdlib.default.dependency=false
二进制
查看文件
二进制文件未显示。
+8
查看文件
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.5.1-bin.zip
networkTimeout=120000
networkRetries=3
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
第三方依赖
+12
查看文件
@@ -0,0 +1,12 @@
#!/bin/sh
APP_HOME=$(cd "${0%/*}" && pwd -P)
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ]; then
JAVACMD=$JAVA_HOME/bin/java
else
JAVACMD=java
fi
exec "$JAVACMD" -Xmx64m -Xms64m -Dorg.gradle.appname=gradlew -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
第三方依赖
+91
查看文件
@@ -0,0 +1,91 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%\bin\java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+8
查看文件
@@ -0,0 +1,8 @@
plugins { id 'com.android.application' }
android {
namespace 'com.sydxky.crabinput.host'
compileSdk 37
defaultConfig { applicationId 'com.sydxky.crabinput.host'; minSdk 27; targetSdk 35; versionCode 10204; versionName '1.2.4' }
compileOptions { sourceCompatibility JavaVersion.VERSION_11; targetCompatibility JavaVersion.VERSION_11 }
}
dependencies { implementation project(':keyboard') }
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:label="键盘接入参考" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false">
<activity android:name=".HostActivity" android:exported="true" android:windowSoftInputMode="stateAlwaysHidden" />
</application>
</manifest>
@@ -0,0 +1,20 @@
package com.sydxky.crabinput.host;
import android.app.Activity;
import android.os.Bundle;
import android.view.Gravity;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.sydxky.crabinput.CrabKeyboard;
/** Separate application demonstrates the public library API, not a system IME. */
public final class HostActivity extends Activity {
private CrabKeyboard.Binding binding;
@Override public void onCreate(Bundle saved){
super.onCreate(saved);LinearLayout layout=new LinearLayout(this);layout.setOrientation(LinearLayout.VERTICAL);layout.setGravity(Gravity.CENTER);layout.setPadding(55,45,55,45);
TextView title=new TextView(this);title.setText("独立应用接入示例");title.setTextSize(13);layout.addView(title);
EditText editor=new EditText(this);editor.setContentDescription("fixture_editor");editor.setHint("点击输入");editor.setLetterSpacing(0f);layout.addView(editor,new LinearLayout.LayoutParams(-1,-2));
layout.setFocusableInTouchMode(true);layout.requestFocus();setContentView(layout);binding=CrabKeyboard.attach(this,editor);
}
@Override protected void onDestroy(){if(binding!=null)binding.close();super.onDestroy();}
}
+13
查看文件
@@ -0,0 +1,13 @@
plugins { id 'com.android.library' }
android {
namespace 'com.sydxky.crabinput.keyboard'
compileSdk 37
defaultConfig { minSdk 27; consumerProguardFiles 'consumer-rules.pro'; testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' }
compileOptions { sourceCompatibility JavaVersion.VERSION_11; targetCompatibility JavaVersion.VERSION_11 }
lint { abortOnError true }
}
dependencies {
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
}
+3
查看文件
@@ -0,0 +1,3 @@
# Public integration API. No reflection is required by the keyboard engine.
-keep public class com.sydxky.crabinput.CrabKeyboard { public *; }
-keep public class com.sydxky.crabinput.CrabKeyboard$Binding { public *; }
@@ -0,0 +1,101 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.os.SystemClock;
import android.text.Editable;
import android.text.InputType;
import android.view.ContextThemeWrapper;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.TextView;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import java.util.Arrays;
import static org.junit.Assert.*;
/** Native offscreen regression: no Activity, foreground switch or system IME. */
public class SurfaceRegressionTest {
private Context context(){return new ContextThemeWrapper(InstrumentationRegistry.getInstrumentation().getTargetContext(),android.R.style.Theme_Material_Light_NoActionBar);}
private void ui(Runnable action){InstrumentationRegistry.getInstrumentation().runOnMainSync(action);}
private View find(View root,String description){
if(description.contentEquals(root.getContentDescription()==null?"":root.getContentDescription()))return root;
if(root instanceof ViewGroup)for(int i=0;i<((ViewGroup)root).getChildCount();i++){View found=find(((ViewGroup)root).getChildAt(i),description);if(found!=null)return found;}
return null;
}
@Test public void textAndKeysSurviveEveryNativeFrame(){
ui(()->{
KeyboardState state=new KeyboardState();state.mode="py";state.preview="你好";
KeyboardSurface surface=new KeyboardSurface(context(),state,new KeyboardSurface.Actions(){public void action(String v){}public void choose(Candidate v){}public void repeatDelete(boolean v){}});
View preview=null,key=null;
for(int frame=0;frame<24;frame++){
state.preview="你好"+frame;state.composition=frame%2==0?"niha":"nihao";
state.busy=frame%2==0;state.candidates=Arrays.asList(new Candidate(frame%2==0?"你好":"您好",5));
// Real native measure/layout/draw, alternating dimensions also
// exercises the resize path without attaching a screen window.
int width=466+frame%2;
surface.measure(View.MeasureSpec.makeMeasureSpec(width,View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(466,View.MeasureSpec.EXACTLY));surface.layout(0,0,width,466);
View now=find(surface,"编辑光标"),q=find(surface,"q");assertNotNull(now);assertNotNull(q);
if(preview!=null){assertSame("Preview is never detached and recreated",preview,now);assertSame("Keys survive async candidate changes",key,q);}
preview=now;key=q;assertSame(surface,now.getParent());assertEquals("你好"+frame,((TextView)now).getText().toString());
Bitmap bitmap=Bitmap.createBitmap(width,466,Bitmap.Config.ARGB_8888);surface.draw(new Canvas(bitmap));int ink=0;
for(int y=now.getTop();y<now.getBottom();y++)for(int x=now.getLeft();x<now.getRight();x++){int color=bitmap.getPixel(x,y);if(Color.red(color)<120&&Color.green(color)<120&&Color.blue(color)<120)ink++;}
assertTrue("Every frame paints the entered text",ink>20);bitmap.recycle();
}
});
}
private KeyboardController session(EditText editor,int type){
EditorInfo info=new EditorInfo();info.inputType=type;
BaseInputConnection input=new BaseInputConnection(editor,true){@Override public Editable getEditable(){return editor.getText();}};
return new KeyboardController(context(),input,info,()->{});
}
private void awaitQuery(KeyboardController controller){
long until=SystemClock.uptimeMillis()+8000;boolean[] busy={true};
do{SystemClock.sleep(40);ui(()->busy[0]=controller.state.busy);}while(busy[0]&&SystemClock.uptimeMillis()<until);
assertFalse("Offline query completes",busy[0]);
}
@Test public void completionAndContinuationCommitExactlyOnce(){
KeyboardController[] value=new KeyboardController[1];EditText[] editor=new EditText[1];
ui(()->{editor[0]=new EditText(context());value[0]=session(editor[0],InputType.TYPE_CLASS_TEXT);value[0].action("mode:py");for(char ch:"niha".toCharArray())value[0].action("key:"+ch);});
KeyboardController controller=value[0];
try{
awaitQuery(controller);ui(()->{assertEquals("你好",controller.state.candidates.get(0).word);controller.choose(controller.state.candidates.get(0));});
awaitQuery(controller);ui(()->{assertEquals("你好",editor[0].getText().toString());assertTrue(controller.state.prediction);assertEquals("世界",controller.state.candidates.get(0).word);controller.choose(controller.state.candidates.get(0));});
awaitQuery(controller);ui(()->assertEquals("你好世界",editor[0].getText().toString()));
}finally{ui(controller::close);}
}
@Test public void passwordDoesNotProvidePredictionContext(){
ui(()->{
EditText editor=new EditText(context());editor.setText("你好");editor.setSelection(editor.length());
KeyboardController controller=session(editor,InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_PASSWORD);
try{controller.action("space");assertTrue(controller.state.secret);assertFalse(controller.state.prediction);assertTrue(controller.state.candidates.isEmpty());assertEquals("",controller.state.preview);}
finally{controller.close();}
});
}
@Test public void caseSwitchTypesBothCasesInsideWatchBounds(){
ui(()->{
EditText editor=new EditText(context());KeyboardController controller=session(editor,InputType.TYPE_CLASS_TEXT);
try{
controller.action("mode:en");KeyboardSurface surface=controller.surface;
int[] widths={466,467,466};String[] keys={"q","Q","q"};String[] switches={"大写","小写"};
for(int step=0;step<3;step++){
int width=widths[step];surface.measure(View.MeasureSpec.makeMeasureSpec(width,View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(466,View.MeasureSpec.EXACTLY));surface.layout(0,0,width,466);
View key=find(surface,keys[step]);assertNotNull(key);assertTrue(key.performClick());
for(int i=0;i<surface.getChildCount();i++){
View child=surface.getChildAt(i);assertTrue(child.getLeft()>=0&&child.getTop()>=0&&child.getRight()<=width&&child.getBottom()<=466);
if(context().getResources().getConfiguration().isScreenRound()&&child instanceof android.widget.Button){
for(int x:new int[]{child.getLeft(),child.getRight()})for(int y:new int[]{child.getTop(),child.getBottom()})assertTrue("Button corners stay inside the round screen",Math.hypot(x-width/2.0,y-233)<=width/2.0-1);
}
}
if(step<2){View toggle=find(surface,switches[step]);assertNotNull(toggle);assertTrue(toggle.performClick());}
}
assertEquals("qQq",editor.getText().toString());assertFalse(controller.state.upper);
}finally{controller.close();}
});
}
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,55 @@
{
"schema_version": 2,
"association_prefixes": 28977,
"association_candidates": 52443,
"keys": 104837,
"candidates": 163238,
"longest_word": 4,
"sha256": "6575841fb5d6232d1624bd91c19e8f39b030eca4a4e09a6bcff21e12b12b27d4",
"bytes": 13688832,
"round_trip": true,
"source_files": {
"key0.dat": "795a5316089b67a7bffabf87080bd61f3720f9e0fd2f2866a092901f99b794e8",
"key1.dat": "a4bcae2b6eb30084d3cf2ac64e5d9ca71eb94e8ac1f7c8fc8b38a5d478982be2",
"key10.dat": "5731d9f3a3cad0304ecc650d74c723185c9d43f74d66d7b5622454d1fc31f99c",
"key11.dat": "fa2a4bfa64cb236a051ceaf6b9b227159dde7aaba21ae997baafac6df4332345",
"key12.dat": "7685ad03e3b5eeac404e8d6f0fc9e8524262b2c4a42ef0301d9e85c64d3db63b",
"key13.dat": "b06f6fc0e2769a8f6e9491a0b97148f8c8d211ba8fe8ed122e1dbffca0eb7399",
"key14.dat": "5d21973e2c1d833e93bfe51c42064b26743f6012e35c73ffcd2b5046cca3eeff",
"key15.dat": "62c3324ccc143cd4e9ee533b0117ee06fc893d58c5d2ed0353da4dde33f5eab1",
"key16.dat": "8c2c0fc7568cf6b674e8630449eb989c7ebdf34f34d0b2041a59748bd20c9638",
"key17.dat": "f62c9c886ca4ee387a780a8c1e24116d73dedfda228de3614978c22f994ef7ec",
"key18.dat": "fa7fda7b3b452908623383a6285aa15b265a41d48de58524fe0f0879b8aad259",
"key19.dat": "00674fb37b56b5973cf0a7ee38b8258652949e4c02782ff6651d2aa63b9c62fc",
"key2.dat": "45e860c89d1362de6007e46a6aa84a0d2fcd9682924de6e5f111cd536e18bfd1",
"key20.dat": "1715a74e0bd00bf89406806682d865a53782d4cd9c4aee5050e547e8a84ffad6",
"key3.dat": "00b464b6ee7ecd7e4406150eecf6c512e22611c6505a5045834cd5c3b7d9068d",
"key4.dat": "a5c5719d09f931efc44b629fe92fd3faea0cc8c5a6ec28d0a9c9b9cdb0eb5ad9",
"key5.dat": "3d5e43374441150af704b9dae9a6d62ac66406306b37709f12119fe44153e353",
"key6.dat": "586c47ee8542f8bf59d7b4bf71fb1715a1bf3e7452d91068edcaf8b566e81e52",
"key7.dat": "4525656f4d9fcd85bff07b876af75eb84f4f711a55a63c258c491d22dfeedff5",
"key8.dat": "8e88d27fbc09fc3b133cbafd3b8e2f1b2269950cfcae12c23c1e152013f63f47",
"key9.dat": "c741aefb551b686a9ade67d3a16a5230d74134af6c3d0faf08557e19698d5825",
"value1.dat": "81c1798a0dda463cacae6ac5285ed9ad0cd0730dde167c18ee3500f1e1d8d8f2",
"value10.dat": "9c4fded85bb7467304df917d510d987a824785058a9f8bb9c9095571b49fbf0d",
"value11.dat": "3be277f87112c7bbc7242882b4ca92ff6e9d38d7be1b51b7899545a5c13d1a19",
"value12.dat": "789c47185b5aaa93293239b356a6a4928d3d29daaf9a4d18f3c94f392f861613",
"value13.dat": "b7366771afebe2ddc466eb9dc8dd840b1e7a8a8214354e2930d4fa4346f27dc6",
"value14.dat": "9c638ef5d554738087505b158482e5b0caba59a36df65b9d0f3e5eee78f91588",
"value15.dat": "846ea5776c9e11f44762cc032d690435464a312e82813530b01cb6001bd0a2f6",
"value16.dat": "b86c42aaf4a818b4eaccd2663b6fb01f3ef3b1dc8482f20636cbde1a95ca52da",
"value17.dat": "2f809c59a318d7d8d8605985092517902b6c627a40936af856785ed596c1133e",
"value18.dat": "d64613d9bc90f503b964984939de0dc881c3a0feda2684bade0c572555c2099d",
"value19.dat": "ecfb7ba56450c671fc941d56556a42d508569e341c5dd2581b18a3d7d27ab55c",
"value2.dat": "d66703e7ac7ab6c2cd3b3c8109e656e1f78ace3e2bb49123ce3a2b3da4ca04d0",
"value20.dat": "896757081c224c72586dfa5babb4300c93639a27ba9a70eb34b9dfd5c23735f2",
"value3.dat": "19c47c2f8b8483946db81572368b3f3b2aa61c5a03953351e327f3f83cb31275",
"value4.dat": "1be64f23183fca36920ae3f002d541febaa1b26b5ca3018ee53ffdd05546d56d",
"value5.dat": "7b8a109e705537784a0a8075cc4fe3d1de8288a6b6b0240627685c81ed9695a9",
"value6.dat": "5b4028a017b228357446f799cd29b28dd5a24d63340f8b48ce858c75c64029f1",
"value7.dat": "b9f6e109ba5f1b5bca758613cb5cccfecf358915ce6415525c333b19515d6428",
"value8.dat": "3d1622d4717680b31a6e037c88e7f046fecfe2339e521272e1d1eae0a7094606",
"value9.dat": "1c0caab370f1f14048ab6dd2da0011bedcddfed0ed0378b5c98171b6b22caf35"
},
"jieba_sha256": "7197c3211ddd98962b036cdf40324d1ea2bfaa12bd028e68faa70111a88e12a8"
}
@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2026 sydxky
Copyright (c) 2026 kqakqakqa
Copyright (c) 2026 crabKun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,17 @@
# 蟹蟹输入 Android手表适配改版
本项目由sydxky基于[CrabKeyboard / 蟹蟹输入](https://github.com/crabPangXie/crabKeyboard)的MIT词库重新实现Android应用内键盘组件、圆屏布局、九宫/14键消歧及候选分页。不是上游原版,也不表示上游作者或设备厂商对本改版作出背书。
上游固定提交:`93d36b5002dee80a3d253b1ec7ec9e29800a104c`。保留原作者kqakqakqa、crabKun的版权声明和MIT许可证;原始词库位于同仓库RTOS目录。Android自研适配部分以[MIT许可证](./LICENSE)发布,**允许商用、修改、再分发和闭源集成**,须保留适用版权及许可全文。
第三方内容继续按各自许可证使用:
| 内容 | 作者/项目 | 许可与位置 |
| --- | --- | --- |
| CrabKeyboard V5词库 | kqakqakqa、crabKun | MIT,`keyboard/src/main/assets/licenses/crabKeyboard-LICENSE.txt` |
| 本地候选排序词频 | jieba 0.42.1 / Sun Junyi | MIT,`keyboard/src/main/assets/licenses/jieba-LICENSE.txt` |
| Gradle wrapper | Gradle | Apache-2.0,`keyboard/src/main/assets/licenses/Gradle-LICENSE.txt` |
14键参考公开演示中的相邻字母分组,自行实现离线消歧;不包含华为内部模型或固件代码。Android SDK、JDK和构建工具不随源码分发,按各自许可使用。AndroidX及JUnit仅用于本地测试,不打入正式APK。
公开仓库:<https://git2.sydxky.cn/sydxky/crab-input-rtos>。匿名问题与建议:<https://git2.sydxky.cn/feedback/sydxky/crab-input-rtos>。
@@ -0,0 +1,422 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
==============================================================================
Licenses for included components:
------------------------------------------------------------------------------
Eclipse Public License 1.0
https://opensource.org/licenses/EPL-1.0
junit:junit
org.sonatype.aether:aether-api
org.sonatype.aether:aether-connector-wagon
org.sonatype.aether:aether-impl
org.sonatype.aether:aether-spi
org.sonatype.aether:aether-util
------------------------------------------------------------------------------
3-Clause BSD
https://opensource.org/licenses/BSD-3-Clause
com.google.code.findbugs:jsr305
org.hamcrest:hamcrest
BSD 3-Clause License
Copyright (c) 2000-2024, www.hamcrest.org
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
com.esotericsoftware.kryo:kryo
com.esotericsoftware.minlog:minlog
Copyright (c) 2008-2018, Nathan Sweet All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
Neither the name of Esoteric Software nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
org.ow2.asm:asm
org.ow2.asm:asm-analysis
org.ow2.asm:asm-commons
org.ow2.asm:asm-tree
org.ow2.asm:asm-util
ASM: a very small and fast Java bytecode manipulation framework
Copyright (c) 2000-2011 INRIA, France Telecom
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holders nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
THE POSSIBILITY OF SUCH DAMAGE.
------------------------------------------------------------------------------
MIT
com.googlecode.plist:dd-plist
dd-plist - An open source library to parse and generate property lists
Copyright (C) 2016 Daniel Dreibrodt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
org.bouncycastle:bcpg-jdk15on
org.bouncycastle:bcprov-jdk15on
Copyright (c) 2000 - 2019 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
org.slf4j:jcl-over-slf4j
org.slf4j:jul-to-slf4j
org.slf4j:log4j-over-slf4j
org.slf4j:slf4j-api
Copyright (c) 2004-2022 QOS.ch Sarl (Switzerland)
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
------------------------------------------------------------------------------
CDDL
https://opensource.org/licenses/CDDL-1.0
com.sun.xml.bind:jaxb-impl
------------------------------------------------------------------------------
LGPL 2.1
https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html
org.samba.jcifs:jcifs
org.jetbrains.intellij.deps:trove4j
------------------------------------------------------------------------------
License for the GNU Trove library included by the Kotlin embeddable compiler
------------------------------------------------------------------------------
The source code for GNU Trove is licensed under the Lesser GNU Public License (LGPL).
Copyright (c) 2001, Eric D. Friedman All Rights Reserved. This library is free software; you can redistribute it and/or modify it under
the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or
(at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU Lesser General Public License along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
Two classes (HashFunctions and PrimeFinder) included in Trove are licensed under the following terms:
Copyright (c) 1999 CERN - European Organization for Nuclear Research. Permission to use, copy, modify, distribute and sell this software
and its documentation for any purpose is hereby granted without fee, provided that the above copyright notice appear in all copies and
that both that copyright notice and this permission notice appear in supporting documentation. CERN makes no representations about the
suitability of this software for any purpose. It is provided "as is" without expressed or implied warranty.
The source code of modified GNU Trove library is available at
https://github.com/JetBrains/intellij-deps-trove4j (with trove4j_changes.txt describing the changes)
------------------------------------------------------------------------------
Eclipse Distribution License 1.0
https://www.eclipse.org/org/documents/edl-v10.php
org.eclipse.jgit:org.eclipse.jgit
------------------------------------------------------------------------------
BSD-style
com.jcraft:jzlib
com.github.mwiede:jsch
Copyright (c) 2002-2015 Atsuhiko Yamanaka, JCraft,Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the distribution.
3. The names of the authors may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JCRAFT,
INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
------------------------------------------------------------------------------
Eclipse Public License 2.0
https://www.eclipse.org/legal/epl-2.0/
org.junit.platform:junit-platform-launcher
------------------------------------------------------------------------------
Mozilla Public License 2.0
https://www.mozilla.org/en-US/MPL/2.0/
org.mozilla:rhino
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 kqakqakqa
Copyright (c) 2026 crabKun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2013 Sun Junyi
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,12 @@
package com.sydxky.crabinput;
public final class Candidate {
public final String word;
public final int consume;
final int frequency;
final boolean completion;
public Candidate(String word, int consume) { this(word,consume,0,false); }
Candidate(String word,int consume,int frequency,boolean completion) {
this.word=word;this.consume=consume;this.frequency=frequency;this.completion=completion;
}
}
@@ -0,0 +1,37 @@
package com.sydxky.crabinput;
import java.util.ArrayList;
import java.util.List;
/** 4 columns x 4 row units; long phrases get full width and extra row units. */
public final class CandidatePager {
public static final class Cell {
public final Candidate candidate;
public final int span;
Cell(Candidate candidate,int span) { this.candidate=candidate; this.span=span; }
}
public static final class Row {
public final List<Cell> cells=new ArrayList<>();
public final int units;
public int slots;
Row(int units) { this.units=units; }
}
public static List<List<Row>> pages(List<Candidate> candidates) {
List<List<Row>> pages=new ArrayList<>();List<Row> page=new ArrayList<>();
Row row=null;int used=0;
for(Candidate candidate:candidates) {
int length=candidate.word.codePointCount(0,candidate.word.length());
int span=length==1?1:length<=4?2:4;
int units=span==4?Math.max(1,(length+11)/12):1;
if(row!=null&&(row.slots+span>4||row.units!=units)) { page.add(row);used+=row.units;row=null; }
if(used+units>4) { if(!page.isEmpty()) pages.add(page);page=new ArrayList<>();used=0; }
if(row==null) row=new Row(units);
row.cells.add(new Cell(candidate,span));row.slots+=span;
if(row.slots==4) { page.add(row);used+=row.units;row=null; }
}
if(row!=null)page.add(row);
if(!page.isEmpty())pages.add(page);
return pages;
}
private CandidatePager() {}
}
@@ -0,0 +1,24 @@
package com.sydxky.crabinput;
import java.util.List;
/** Shared policy for complete matches, unfinished syllables and prefix fallbacks. */
final class CandidateRanking {
private static int category(Candidate item,int length) {
return item.completion?1:item.consume==length?0:2;
}
private static int contextRank(Candidate item,List<Candidate> predictions) {
for(int i=0;i<predictions.size();i++)if(predictions.get(i).word.equals(item.word))return 8-i;
return 0;
}
static void sort(List<Candidate> items,int length,List<Candidate> predictions) {
items.sort((a,b)->{
int order=Integer.compare(category(a,length),category(b,length));
if(order==0)order=Integer.compare(b.consume,a.consume);
if(order==0)order=Integer.compare(contextRank(b,predictions),contextRank(a,predictions));
if(order==0)order=Integer.compare(b.frequency,a.frequency);
return order;
});
}
private CandidateRanking(){}
}
@@ -0,0 +1,61 @@
package com.sydxky.crabinput;
import android.app.Activity;
import android.app.Application;
import android.app.Dialog;
import android.os.Bundle;
import android.text.Editable;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
/** Public AAR entry point. Works inside the host app; no system IME settings. */
public final class CrabKeyboard {
public static Binding attach(Activity activity,EditText editor){return new Binding(activity,editor);}
public static final class Binding implements AutoCloseable,Application.ActivityLifecycleCallbacks {
private final Activity activity;
private final EditText editor;
private final boolean originalShowSoftInput;
private Dialog dialog;
private boolean closed;
private Binding(Activity activity,EditText editor){
if(android.os.Looper.myLooper()!=android.os.Looper.getMainLooper())throw new IllegalStateException("Attach on the UI thread");
this.activity=activity;this.editor=editor;originalShowSoftInput=editor.getShowSoftInputOnFocus();editor.setShowSoftInputOnFocus(false);
editor.setOnFocusChangeListener((view,focus)->{if(focus)show();});editor.setOnClickListener(view->show());activity.getApplication().registerActivityLifecycleCallbacks(this);
}
public void show(){
if(closed||dialog!=null||activity.isFinishing()||activity.isDestroyed())return;
if(editor.getSelectionStart()<0)editor.setSelection(editor.length());
EditorInfo info=new EditorInfo();info.inputType=editor.getInputType();info.imeOptions=editor.getImeOptions();
BaseInputConnection connection=new BaseInputConnection(editor,true){
@Override public Editable getEditable(){return editor.getText();}
@Override public boolean sendKeyEvent(KeyEvent event){return editor.dispatchKeyEvent(event);}
@Override public boolean performEditorAction(int actionCode){editor.onEditorAction(actionCode);return true;}
};
Dialog session=new Dialog(activity,android.R.style.Theme_Material_Light_NoActionBar);dialog=session;
KeyboardController controller=new KeyboardController(activity,connection,info,session::dismiss);
session.setOwnerActivity(activity);session.setContentView(controller.surface,new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,controller.surface.desiredHeight()));
Window window=session.getWindow();
if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);window.setGravity(Gravity.BOTTOM);
if(activity.getResources().getConfiguration().isScreenRound()){window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN|View.SYSTEM_UI_FLAG_HIDE_NAVIGATION|View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);}}
session.setOnDismissListener(ignored->{controller.close();connection.closeConnection();if(dialog==session)dialog=null;});session.show();
if(window!=null)window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT,activity.getResources().getConfiguration().isScreenRound()?ViewGroup.LayoutParams.MATCH_PARENT:ViewGroup.LayoutParams.WRAP_CONTENT);
}
public void dismiss(){if(dialog!=null)dialog.dismiss();}
@Override public void close(){if(closed)return;closed=true;dismiss();editor.setOnFocusChangeListener(null);editor.setOnClickListener(null);editor.setShowSoftInputOnFocus(originalShowSoftInput);activity.getApplication().unregisterActivityLifecycleCallbacks(this);}
@Override public void onActivityStopped(Activity value){if(value==activity)dismiss();}
@Override public void onActivityDestroyed(Activity value){if(value==activity)close();}
@Override public void onActivityCreated(Activity value,Bundle state){}
@Override public void onActivityStarted(Activity value){}
@Override public void onActivityResumed(Activity value){}
@Override public void onActivityPaused(Activity value){}
@Override public void onActivitySaveInstanceState(Activity value,Bundle state){}
}
private CrabKeyboard(){}
}
@@ -0,0 +1,115 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.text.InputType;
import android.view.KeyEvent;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
/** One transient in-app editing session. It never selects a system IME. */
final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable {
private final Handler main=new Handler(Looper.getMainLooper());
private final ExecutorService worker=Executors.newSingleThreadExecutor();
final KeyboardState state=new KeyboardState();
final KeyboardSurface surface;
private final Lexicon lexicon;
private final InputConnection input;
private final EditorInfo info;
private final Runnable dismiss;
private final Context context;
private Future<?> pending;
private int revision;
private boolean active=true,repeat,closed;
private final Runnable lookup=()->query(revision,state.composition,state.mode,contextText());
private final Runnable deleteAgain=new Runnable(){@Override public void run(){if(repeat&&active){delete();main.postDelayed(this,115);}}};
@android.annotation.SuppressLint("WearPasswordInput") // Detect host password editors, no account login.
KeyboardController(Context context,InputConnection input,EditorInfo info,Runnable dismiss){
this.context=context;this.input=input;this.info=info;this.dismiss=dismiss;lexicon=new Lexicon(context);
int kind=info.inputType&InputType.TYPE_MASK_CLASS,variation=info.inputType&InputType.TYPE_MASK_VARIATION;
state.secret=kind==InputType.TYPE_CLASS_TEXT&&(variation==InputType.TYPE_TEXT_VARIATION_PASSWORD||variation==InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD||variation==InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD)||kind==InputType.TYPE_CLASS_NUMBER&&variation==InputType.TYPE_NUMBER_VARIATION_PASSWORD;
state.mode=kind==InputType.TYPE_CLASS_NUMBER||kind==InputType.TYPE_CLASS_PHONE?"num":state.secret||variation==InputType.TYPE_TEXT_VARIATION_URI||variation==InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS?"en":"t9";
surface=new KeyboardSurface(context,state,this);refresh();
}
private void invalidateLookup(){revision++;main.removeCallbacks(lookup);if(pending!=null){pending.cancel(true);pending=null;}state.busy=false;}
private void refresh(){
if(active&&!state.secret){CharSequence text=input.getTextBeforeCursor(24,0);state.preview=text==null?"":text.toString();}else state.preview="";
if(!repeat)surface.refresh();
}
private void message(String text){state.message=text;android.widget.Toast.makeText(context,text,android.widget.Toast.LENGTH_SHORT).show();refresh();}
private boolean chinese(){return state.mode.equals("py")||state.mode.equals("t9")||state.mode.equals("14");}
private String contextText(){if(state.secret)return "";CharSequence text=input.getTextBeforeCursor(12,0);return text==null?"":text.toString();}
private void clearCandidates(){state.candidates=new ArrayList<>();state.pages=new ArrayList<>();}
private void schedule(){
invalidateLookup();state.page=0;
boolean prediction=state.composition.isEmpty();
if(!active||!chinese()||prediction&&(state.secret||!contextText().matches("(?s).*[\\u3400-\\u9fff]$"))){clearCandidates();state.prediction=false;refresh();return;}
if(prediction!=state.prediction)clearCandidates();
state.prediction=prediction;state.busy=true;main.postDelayed(lookup,40);refresh();
}
private void query(int owner,String composition,String mode,String context){
pending=worker.submit(()->{
try {List<Candidate> result=composition.isEmpty()?lexicon.predict(context):lexicon.lookup(composition,mode,context);main.post(()->{if(!active||revision!=owner)return;state.candidates=result;state.pages=CandidatePager.pages(result);state.page=0;state.busy=false;state.message=result.isEmpty()&&!composition.isEmpty()?"暂无匹配,请退格修改":state.prediction?"联想":"离线输入";refresh();});}
catch(Exception ignored){main.post(()->{if(active&&revision==owner){clearCandidates();state.busy=false;state.message="词库暂不可用,可切换英文";refresh();}});}
});
}
private void commit(String text){if(!active)return;input.commitText(text,1);if(chinese())schedule();else refresh();}
@Override public void choose(Candidate item){
if(!active||state.busy||state.composition.isEmpty()&&(!state.prediction||item.consume!=0)||!state.candidates.contains(item))return;
String remaining=state.composition.substring(Math.min(item.consume,state.composition.length()));
invalidateLookup();input.beginBatchEdit();
try{input.commitText(item.word,1);state.composition=remaining;state.expanded=false;}
finally{input.endBatchEdit();}
schedule();
}
private void delete(){
if(!active)return;
if(!state.composition.isEmpty()){state.composition=state.composition.substring(0,state.composition.length()-1);schedule();return;}
CharSequence selection=input.getSelectedText(0);if(selection!=null&&selection.length()>0)input.commitText("",1);else input.deleteSurroundingTextInCodePoints(1,0);schedule();
}
@Override public void repeatDelete(boolean start){boolean previous=repeat;main.removeCallbacks(deleteAgain);repeat=start&&active;if(repeat){delete();main.postDelayed(deleteAgain,450);}else if(previous)refresh();}
@Override public void action(String value){
if(!active)return;
if(value.startsWith("key:")){
String key=value.substring(4);
if(chinese()){if(state.composition.length()<32){state.composition+=key;schedule();}else message("每段最多32位");}else commit(key);return;
}
if(value.startsWith("mode:")){
String mode=value.substring(5);if(!state.composition.isEmpty()&&!mode.equals(state.mode)){message("请先选择候选词");return;}
if(mode.equals("en")&&state.mode.equals("en"))state.upper=!state.upper;
if(mode.equals("sym"))state.symbolPage=state.mode.equals("sym")?(state.symbolPage+1)%3:0;
state.mode=mode;state.expanded=false;state.tools=false;schedule();return;
}
switch(value){
case "delete":delete();return;
case "space":if(!state.composition.isEmpty()){if(!state.busy&&!state.candidates.isEmpty())choose(state.candidates.get(0));}else commit(" ");return;
case "expand":if(!state.candidates.isEmpty()){state.expanded=true;state.tools=false;state.page=0;}break;
case "previous":state.page=Math.max(0,state.page-1);break;
case "next":state.page=Math.min(Math.max(0,state.pages.size()-1),state.page+1);break;
case "back":state.expanded=false;state.tools=false;break;
case "tools":state.expanded=false;state.tools=true;break;
case "clear-composition":state.composition="";schedule();return;
case "hide":dismiss.run();return;
case "left":case "right":if(state.composition.isEmpty()){int key=value.equals("left")?KeyEvent.KEYCODE_DPAD_LEFT:KeyEvent.KEYCODE_DPAD_RIGHT;input.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN,key));input.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP,key));schedule();return;}break;
case "raw":if(state.mode.equals("py")&&!state.composition.isEmpty()){String text=state.composition;state.composition="";commit(text);}return;
case "newline":if(!state.secret&&(info.inputType&InputType.TYPE_TEXT_FLAG_MULTI_LINE)!=0)commit("\n");break;
case "done":
if(!state.composition.isEmpty()){message("请先选择候选词");state.expanded=false;break;}
int editorAction=info.imeOptions&EditorInfo.IME_MASK_ACTION;
if(editorAction!=EditorInfo.IME_ACTION_NONE&&editorAction!=EditorInfo.IME_ACTION_UNSPECIFIED)input.performEditorAction(editorAction);
dismiss.run();return;
default:break;
}
refresh();
}
@Override public void close(){
if(closed)return;closed=true;active=false;invalidateLookup();repeatDelete(false);main.removeCallbacksAndMessages(null);
state.composition="";state.preview="";state.candidates=new ArrayList<>();state.pages=new ArrayList<>();worker.execute(lexicon::close);worker.shutdown();
}
}
@@ -0,0 +1,12 @@
package com.sydxky.crabinput;
import java.util.ArrayList;
import java.util.List;
final class KeyboardState {
String mode="t9",composition="",preview="",message="离线输入";
boolean expanded,tools,upper,secret,busy,prediction;
int page,symbolPage;
List<Candidate> candidates=new ArrayList<>();
List<List<CandidatePager.Row>> pages=new ArrayList<>();
}
@@ -0,0 +1,196 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.RippleDrawable;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.LinkedHashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Native accessible buttons laid out within the actual round screen chord. */
@android.annotation.SuppressLint("ViewConstructor") // Created by the IME, never inflated from XML.
final class KeyboardSurface extends ViewGroup {
interface Actions { void action(String value);void choose(Candidate item);void repeatDelete(boolean start); }
private static final int BLUE=Color.rgb(40,103,217),INK=Color.rgb(32,35,43),MUTED=Color.rgb(132,139,151),BG=Color.rgb(236,238,242);
private final KeyboardState state;
private final Actions actions;
private final boolean round;
private final List<Placement> placements=new ArrayList<>();
private final Map<String,View> children=new LinkedHashMap<>();
private final Set<String> used=new HashSet<>();
private boolean refreshPosted;
private int builtWidth,builtHeight;
private final Runnable render=()->{refreshPosted=false;if(builtWidth>0)build(builtWidth,builtHeight);requestLayout();invalidate();};
private float sx=1,sy=1;
private static final class Placement {
final View view;final Rect bounds;
Placement(View view,Rect bounds){this.view=view;this.bounds=bounds;}
}
KeyboardSurface(Context context,KeyboardState state,Actions actions) {
super(context);this.state=state;this.actions=actions;round=getResources().getConfiguration().isScreenRound();
setBackgroundColor(BG);setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);setContentDescription("蟹蟹输入键盘");
}
int desiredHeight() {
android.util.DisplayMetrics dm=getResources().getDisplayMetrics();
boolean watch=round||(dm.heightPixels/(float)dm.widthPixels<1.35f);
return watch?dm.heightPixels:Math.min(dm.widthPixels,Math.round(dm.heightPixels*.46f));
}
void refresh() {if(!refreshPosted){refreshPosted=true;postOnAnimation(render);}}
private Rect bounds(float x,float y,float width,float height) {
return new Rect(Math.round(x*sx),Math.round(y*sy),Math.round((x+width)*sx),Math.round((y+height)*sy));
}
private float inset(float y,float height) {
if(!round)return 8;
float cy=builtHeight/2f,r=builtWidth/2f-7*sx;
float distance=Math.max(Math.abs(y*sy-cy),Math.abs((y+height)*sy-cy));
float half=(float)Math.sqrt(Math.max(0,r*r-distance*distance));
return Math.max(8,(builtWidth/2f-half)/sx+2);
}
private void place(View view,float x,float y,float width,float height) {
if(view.getParent()==null)addView(view);placements.add(new Placement(view,bounds(x,y,width,height)));
}
private TextView label(String key,String text,float font,int color) {
used.add(key);TextView view=(TextView)children.get(key);
if(view==null){view=new TextView(getContext());children.put(key,view);}
if(!view.getText().toString().equals(text))view.setText(text);view.setTextColor(color);view.setGravity(Gravity.CENTER);
view.setTextSize(TypedValue.COMPLEX_UNIT_PX,font*Math.min(sx,sy));view.setIncludeFontPadding(false);return view;
}
private Button button(String key,String text,String description,float font,boolean primary,Runnable action) {
used.add(key);Button button=(Button)children.get(key);boolean created=button==null;
if(created){button=new Button(getContext());children.put(key,button);}
if(!button.getText().toString().equals(text))button.setText(text);button.setContentDescription(description);button.setAllCaps(false);
button.setGravity(Gravity.CENTER);button.setIncludeFontPadding(false);button.setMinHeight(0);button.setMinimumHeight(0);button.setMinWidth(0);button.setMinimumWidth(0);
button.setPadding(Math.round(3*sx),0,Math.round(3*sx),0);button.setTextSize(TypedValue.COMPLEX_UNIT_PX,font*Math.min(sx,sy));
button.setTextColor(primary?Color.WHITE:INK);button.setStateListAnimator(null);
if(created){GradientDrawable shape=new GradientDrawable();shape.setColor(primary?BLUE:Color.WHITE);shape.setCornerRadius(10*Math.min(sx,sy));button.setBackground(new RippleDrawable(ColorStateList.valueOf(0x332867D9),shape,null));}
button.setOnClickListener(v->action.run());return button;
}
// Native Button click remains available to accessibility; touch deletion starts on down.
@android.annotation.SuppressLint("ClickableViewAccessibility")
private void action(String text,String value,float x,float y,float width,float height,float font,boolean primary) {
String name=value.equals("delete")?"删除":value.equals("done")?"完成":value.equals("hide")?"收起键盘":value.equals("expand")?"更多候选":value.equals("back")?"返回键盘":value.equals("previous")?"上一页":value.equals("next")?"下一页":text;
Button button=button("action:"+value,text,name,font,primary,()->actions.action(value));
if(value.equals("delete"))button.setOnTouchListener((v,event)->{
if(event.getActionMasked()==MotionEvent.ACTION_DOWN)actions.repeatDelete(true);
if(event.getActionMasked()==MotionEvent.ACTION_UP||event.getActionMasked()==MotionEvent.ACTION_CANCEL)actions.repeatDelete(false);
return true;
});
place(button,x,y,width,height);
}
private void row(String[] captions,String[] values,float y,float height,float font) {
float inset=inset(y,height),width=410-2*inset,cell=width/captions.length;
for(int i=0;i<captions.length;i++)action(captions[i],values[i],inset+i*cell+2,y,cell-4,height,font,false);
}
private void build(int width,int height) {
builtWidth=width;builtHeight=height;sx=width/410f;sy=height/410f;placements.clear();used.clear();
try{
float top=round?28:7;
float headerInset=inset(top,23);
action("⌄","hide",headerInset,top,32,23,23,false);
String title=state.expanded?"完整候选":state.tools?"编辑工具":"蟹蟹输入";
place(label("title",title,14,MUTED),headerInset+34,top,410-2*headerInset-68,23);
action("编辑","tools",410-headerInset-32,top,32,23,10,false);
float previewY=top+29;
String preview=state.secret?"私密输入":state.preview.isEmpty()?"开始输入…":state.preview;
TextView previewView=label("preview",preview,21,INK);previewView.setMaxLines(1);previewView.setEllipsize(android.text.TextUtils.TruncateAt.START);
previewView.setLetterSpacing(0f);
previewView.setOnClickListener(v->actions.action("tools"));previewView.setContentDescription("编辑光标");
float pi=inset(previewY,30);place(previewView,pi,previewY,410-2*pi,30);
if(state.expanded) { buildCandidates(previewY+38);return; }
if(state.tools) { buildTools(previewY+40);return; }
float compositionY=previewY+32;
String composition=state.composition;
if(state.mode.equals("14")) { StringBuilder display=new StringBuilder();for(char ch:composition.substring(Math.max(0,composition.length()-6)).toCharArray())display.append(Lexicon.GROUP14["0123456789abcd".indexOf(ch)].toUpperCase(Locale.ROOT)).append(' ');composition=display.toString(); }
TextView compose=label("composition",composition.isEmpty()?modeName():composition,15,BLUE);compose.setMaxLines(1);
float ci=inset(compositionY,23);place(compose,ci,compositionY,410-2*ci,23);
float candidateY=compositionY+26;
if(isChinese()) {
float left=inset(candidateY,32),available=410-2*left;
int count=Math.min(3,state.candidates.size());float cell=(available-37)/Math.max(1,count);
for(int i=0;i<count;i++){Candidate item=state.candidates.get(i);Button b=button("candidate:"+i,item.word,item.word,22,false,()->actions.choose(item));b.setEnabled(!state.busy);b.setMaxLines(1);b.setAutoSizeTextTypeUniformWithConfiguration(10,22,1,TypedValue.COMPLEX_UNIT_PX);place(b,left+i*cell+2,candidateY,cell-4,32);}
if(count>0)action("⌄","expand",410-left-35,candidateY,33,32,24,false);
else place(label("candidate-status",state.busy?"正在查词…":state.message,12,MUTED),left,candidateY,available,32);
}
// A direct-entry mode has no candidate strip; attach its keys to the
// composition caption instead of reserving an invisible suggestion row.
float keyY=isChinese()?candidateY+38:compositionY+25;
String[][] captions,values;
switch(state.mode) {
case "t9":captions=new String[][]{{"标点","ABC","DEF"},{"GHI","JKL","MNO"},{"PQRS","TUV","WXYZ"}};values=new String[][]{{"mode:sym","key:2","key:3"},{"key:4","key:5","key:6"},{"key:7","key:8","key:9"}};break;
case "14":captions=new String[][]{{"QW","ER","TY","UI","OP"},{"AS","DF","GH","JK","L"},{"ZX","CV","BN","M"}};values=new String[][]{{"key:0","key:1","key:2","key:3","key:4"},{"key:5","key:6","key:7","key:8","key:9"},{"key:a","key:b","key:c","key:d"}};break;
case "num":captions=new String[][]{{"1","2","3"},{"4","5","6"},{"7","8","9"},{".","0","-"}};values=values(captions);break;
case "sym":String[] symbols={",。?!:;","()【】《》","、…·“”「", "!\"#$%&","'()*+,","-./:;<", "=>?@[\\","]^_`{|","}~€£¥°"};captions=new String[][]{split(symbols[state.symbolPage*3]),split(symbols[state.symbolPage*3+1]),split(symbols[state.symbolPage*3+2])};values=values(captions);break;
default:captions=new String[][]{split("qwertyuiop"),split("asdfghjkl"),split("zxcvbnm")};if(state.upper&&state.mode.equals("en"))for(String[] line:captions)for(int i=0;i<line.length;i++)line[i]=line[i].toUpperCase(Locale.ROOT);values=values(captions);
}
float keyHeight=captions.length==4?36:49;
// Hide an unused suggestion strip in direct entry modes to enlarge the keys.
if(!isChinese()){keyHeight=captions.length==4?43:66;}
else if(state.mode.equals("py")){keyHeight=55;}
for(int i=0;i<captions.length;i++)row(captions[i],values[i],keyY+i*(keyHeight+4),keyHeight,captions[i].length>=7?21:24);
float modeY=keyY+captions.length*(keyHeight+4)+3;
row(new String[]{"九宫","26键","14键",state.mode.equals("en")?(state.upper?"小写":"大写"):"abc","123","符号"},new String[]{"mode:t9","mode:py","mode:14","mode:en","mode:num","mode:sym"},modeY,27,13);
bottom(modeY+34,false);
}finally{
java.util.Iterator<Map.Entry<String,View>> iterator=children.entrySet().iterator();
while(iterator.hasNext()){Map.Entry<String,View> entry=iterator.next();if(!used.contains(entry.getKey())){removeView(entry.getValue());iterator.remove();}}
}
}
private void buildCandidates(float y) {
int count=state.pages.size();float safe=inset(y,18);
place(label("page-info",state.candidates.size()+" 项 · "+(state.page+1)+" / "+Math.max(1,count),12,MUTED),safe,y,410-2*safe,18);
float gridY=y+25;int unit=43;
int rowIndex=0;
if(count>0)for(CandidatePager.Row row:state.pages.get(state.page)) {
float rowHeight=row.units*unit-4,left=inset(gridY,rowHeight),cell=(410-2*left)/4;int slot=0;
for(CandidatePager.Cell entry:row.cells) {
Candidate item=entry.candidate;Button button=button("grid:"+rowIndex+":"+slot,item.word,item.word,entry.span==1?25:entry.span==2?23:20,false,()->actions.choose(item));button.setEnabled(!state.busy);
button.setMaxLines(row.units*2);button.setEllipsize(null);
place(button,left+slot*cell+2,gridY,entry.span*cell-4,rowHeight);slot+=entry.span;
}
gridY+=row.units*unit;rowIndex++;
}
float pagerY=y+25+4*unit+3;
row(new String[]{"‹","›"},new String[]{"previous","next"},pagerY,29,27);
bottom(pagerY+36,true);
}
private void buildTools(float y) {
row(new String[]{"← 光标","光标 →"},new String[]{"left","right"},y+7,37,18);
row(new String[]{"换行","拼音上屏"},new String[]{"newline","raw"},y+53,37,18);
row(new String[]{"清除拼音","空格"},new String[]{"clear-composition","space"},y+99,37,18);
place(label("privacy",state.secret?"私密输入 · 不学习、不保存":"完全离线 · 不采集输入内容",12,MUTED),45,y+150,320,26);
bottom(y+190,true);
}
private void bottom(float y,boolean back) {
float left=inset(y,34),cell=(410-2*left)/3;
action(back?"键盘":"空格",back?"back":"space",left+2,y,cell-4,34,17,false);
action("⌫","delete",left+cell+2,y,cell-4,34,25,false);
action("✓","done",left+2*cell+2,y,cell-4,34,23,true);
}
private boolean isChinese(){return state.mode.equals("py")||state.mode.equals("t9")||state.mode.equals("14");}
private String modeName(){return state.mode.equals("py")?"26键全拼 · v 代表 ü":state.mode.equals("t9")?"九宫拼音":state.mode.equals("14")?"14键拼音":state.mode.equals("en")?"英文":state.mode.equals("sym")?"符号 · "+(state.symbolPage+1)+"/3 · 再点符号翻页":"数字";}
private static String[] split(String text){String[] result=new String[text.length()];for(int i=0;i<text.length();i++)result[i]=text.substring(i,i+1);return result;}
private static String[][] values(String[][] captions){String[][] result=new String[captions.length][];for(int i=0;i<captions.length;i++){result[i]=new String[captions[i].length];for(int j=0;j<result[i].length;j++)result[i][j]="key:"+captions[i][j];}return result;}
@Override protected void onMeasure(int widthSpec,int heightSpec) {
int width=MeasureSpec.getSize(widthSpec),height=resolveSize(desiredHeight(),heightSpec);
if(width!=builtWidth||height!=builtHeight)build(width,height);
for(Placement item:placements)item.view.measure(MeasureSpec.makeMeasureSpec(item.bounds.width(),MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(item.bounds.height(),MeasureSpec.EXACTLY));
setMeasuredDimension(width,height);
}
@Override protected void onLayout(boolean changed,int l,int t,int r,int b){for(Placement item:placements)item.view.layout(item.bounds.left,item.bounds.top,item.bounds.right,item.bounds.bottom);}
@Override public boolean dispatchTouchEvent(MotionEvent event){if(event.getActionMasked()==MotionEvent.ACTION_UP||event.getActionMasked()==MotionEvent.ACTION_CANCEL)actions.repeatDelete(false);return super.dispatchTouchEvent(event);}
@Override protected void onDetachedFromWindow(){removeCallbacks(render);refreshPosted=false;actions.repeatDelete(false);super.onDetachedFromWindow();}
}
@@ -0,0 +1,88 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** Full upstream dictionary, offline. Called only on the service's single worker. */
public final class Lexicon implements AutoCloseable {
public static final String[] GROUP14={"qw","er","ty","ui","op","as","df","gh","jk","l","zx","cv","bn","m"};
public static final String[] GROUP9={"abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
private final Context context;
private SQLiteDatabase db;
public Lexicon(Context context) { this.context=context.getApplicationContext(); }
public static String encode(String text,String mode) {
String[] groups=mode.equals("14")?GROUP14:GROUP9;String tokens=mode.equals("14")?"0123456789abcd":"23456789";
StringBuilder result=new StringBuilder();
for(char ch:text.toLowerCase(java.util.Locale.ROOT).toCharArray()) {
int index=-1;for(int i=0;i<groups.length;i++)if(groups[i].indexOf(ch)>=0){index=i;break;}
if(index<0)return "";result.append(tokens.charAt(index));
}
return result.toString();
}
private void open() throws Exception {
if(db!=null)return;
File directory=context.getNoBackupFilesDir();File target=new File(directory,"crab-v5-2.db");
if(!target.isFile()) {
File temporary=new File(directory,"crab-v5-2.tmp");
try(InputStream input=context.getAssets().open("dictionary.db");FileOutputStream output=new FileOutputStream(temporary)) {
byte[] buffer=new byte[32768];int length;while((length=input.read(buffer))!=-1)output.write(buffer,0,length);output.getFD().sync();
}
if(!temporary.renameTo(target))throw new java.io.IOException("Dictionary installation failed");
}
db=SQLiteDatabase.openDatabase(target.getPath(),null,SQLiteDatabase.OPEN_READONLY|SQLiteDatabase.NO_LOCALIZED_COLLATORS);
}
public List<Candidate> lookup(String input,String mode) throws Exception {
return lookup(input,mode,"");
}
public List<Candidate> predict(String context) throws Exception {
List<Candidate> result=new ArrayList<>();
java.util.regex.Matcher match=java.util.regex.Pattern.compile("[\\u3400-\\u9fff]{1,4}$").matcher(context==null?"":context);
if(!match.find())return result;
open();String tail=match.group();Set<String> seen=new HashSet<>();
for(int length=tail.length();length>0&&result.size()<8;length--){
try(Cursor cursor=db.rawQuery("SELECT word,frequency FROM associations WHERE prefix=? ORDER BY frequency DESC,word LIMIT 8",new String[]{tail.substring(tail.length()-length)})){
while(cursor.moveToNext()&&result.size()<8){String word=cursor.getString(0);if(seen.add(word))result.add(new Candidate(word,0,cursor.getInt(1),false));}
}
}
return result;
}
public List<Candidate> lookup(String input,String mode,String context) throws Exception {
List<Candidate> result=new ArrayList<>();
String pattern=mode.equals("14")?"[0-9a-d]{1,32}":mode.equals("t9")?"[2-9]{1,32}":"[a-z]{1,32}";
if(!input.matches(pattern))return result;open();
String column=mode.equals("14")?"code14":mode.equals("t9")?"code9":"pinyin";
StringBuilder marks=new StringBuilder();String[] prefixes=new String[input.length()];
for(int i=0;i<input.length();i++){if(i>0)marks.append(',');marks.append('?');prefixes[i]=input.substring(0,i+1);}
String order=mode.equals("py")?"length(e.pinyin) DESC,e.pinyin,w.ordinal":"length(e.pinyin) DESC,w.frequency DESC,e.pinyin,w.ordinal";
Set<String> seen=new HashSet<>();
try(Cursor cursor=db.rawQuery("SELECT w.word,length(e.pinyin),w.frequency FROM entries e JOIN words w ON w.entry=e.id WHERE e."+column+" IN ("+marks+") ORDER BY "+order,prefixes)) {
while(cursor.moveToNext()) {
if(Thread.currentThread().isInterrupted())return new ArrayList<>();
String word=cursor.getString(0);int consume=cursor.getInt(1);
if(seen.add(word+"@"+consume))result.add(new Candidate(word,consume,cursor.getInt(2),false));
}
}
if(input.length()>=2){
String sql="SELECT w.word,w.frequency FROM entries e JOIN words w ON w.entry=e.id WHERE e."+column+">? AND e."+column+"<? AND length(e.pinyin)<=? ORDER BY w.frequency DESC,length(e.pinyin),e.pinyin,w.ordinal LIMIT 128";
try(Cursor cursor=db.rawQuery(sql,new String[]{input,input+'\uffff',Integer.toString(input.length()+4)})){
int added=0;
while(cursor.moveToNext()&&added<12){
if(Thread.currentThread().isInterrupted())return new ArrayList<>();
String word=cursor.getString(0);
if(seen.add(word+"@"+input.length())){result.add(new Candidate(word,input.length(),cursor.getInt(1),true));added++;}
}
}
}
CandidateRanking.sort(result,input.length(),predict(context));
return result;
}
@Override public void close() { if(db!=null){db.close();db=null;} }
}
@@ -0,0 +1,26 @@
package com.sydxky.crabinput;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.*;
public class CandidatePagerTest {
@Test public void singleCharactersFillSixteenCellsAndKeepAllResults(){
List<Candidate> input=new ArrayList<>();for(int i=0;i<90;i++)input.add(new Candidate(String.valueOf((char)(0x4E00+i)),3));
List<List<CandidatePager.Row>> pages=CandidatePager.pages(input);assertEquals(6,pages.size());assertEquals(4,pages.get(0).size());
List<Candidate> restored=new ArrayList<>();for(List<CandidatePager.Row> page:pages)for(CandidatePager.Row row:page)for(CandidatePager.Cell cell:row.cells)restored.add(cell.candidate);
assertEquals(input,restored);assertEquals(16,pages.get(0).stream().mapToInt(row->row.cells.size()).sum());
}
@Test public void phrasesUseWiderCellsAndLongPhrasesGetExtraRows(){
List<Candidate> input=Arrays.asList(new Candidate("你好",5),new Candidate("完整词组",4),new Candidate("长词组必须完整展示而不能省略任何文字",19),new Candidate("😀",1));
List<List<CandidatePager.Row>> pages=CandidatePager.pages(input);assertEquals(2,pages.get(0).get(0).cells.size());assertEquals(2,pages.get(0).get(0).cells.get(0).span);
assertEquals(2,pages.get(0).get(1).units);assertEquals(1,pages.get(0).get(2).cells.get(0).span);
for(List<CandidatePager.Row> page:pages)assertTrue(page.stream().mapToInt(row->row.units).sum()<=4);
}
@Test public void emptyListHasNoPhantomButtons(){assertTrue(CandidatePager.pages(new ArrayList<>()).isEmpty());}
@Test public void pairedAndT9MappingsCoverExpectedWords(){
assertEquals("c3754",Lexicon.encode("nihao","14"));assertEquals("64426",Lexicon.encode("nihao","t9"));assertEquals("",Lexicon.encode("a!","14"));
}
}
@@ -0,0 +1,29 @@
package com.sydxky.crabinput;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.Test;
import static org.junit.Assert.*;
public class CandidateRankingTest {
@Test public void incompleteSyllablesBeatShorterFallbackWithoutLosingThem(){
Candidate shorter=new Candidate("你",2,234587,false),complete=new Candidate("你好",4,725,true);
List<Candidate> items=new ArrayList<>(Arrays.asList(shorter,complete));
CandidateRanking.sort(items,4,Collections.emptyList());
assertSame(complete,items.get(0));assertTrue(items.contains(shorter));
}
@Test public void exactSpellingBeatsCompletionsAndUsesWordFrequency(){
Candidate common=new Candidate("世界",6,34387,false),rare=new Candidate("使节",6,497,false),longer=new Candidate("世界观",6,100000,true);
List<Candidate> items=new ArrayList<>(Arrays.asList(rare,longer,common));
CandidateRanking.sort(items,6,Collections.emptyList());
assertEquals(Arrays.asList(common,rare,longer),items);
}
@Test public void matchingContextDisambiguatesWithoutInjectingCandidates(){
Candidate plain=new Candidate("背景",7,34488,false),contextual=new Candidate("北京",7,3342,false);
List<Candidate> items=new ArrayList<>(Arrays.asList(plain,contextual));
CandidateRanking.sort(items,7,Arrays.asList(new Candidate("北京",0),new Candidate("不存在",0)));
assertSame(contextual,items.get(0));assertEquals(2,items.size());
}
}
+56
查看文件
@@ -0,0 +1,56 @@
"""Losslessly build the Android SQLite asset from the licensed original V5 trie."""
import argparse,hashlib,importlib.util,json,pathlib,sqlite3
APP=pathlib.Path(__file__).resolve().parents[1]
def build(rtos):
source=rtos/'third_party/crabKeyboard/pinyinDictV5_20260416'
files={p.name:p.read_bytes()for p in source.glob('*.dat')};entries={}
assert len(files)==41,'Expected the complete upstream V5 dictionary'
def number(text):
result=0
for ch in text:assert 33<=ord(ch)<=126;result=result*94+ord(ch)-33
return result
def walk(key,depth,position,length):
for item in files[f'key{depth}.dat'][position:position+length].decode('ascii').split('\x1e'):
if not item:continue
offset,size=map(number,item[1:].split('\x1f'))
if item[0]=='_':entries[key]=[word for word in files[f'value{depth}.dat'][offset:offset+size].decode('utf-8').split(' ')if word]
else:walk(key+item[0],depth+1,offset,size)
walk('',0,0,len(files['key0.dat']))
frequency_path=rtos/'third_party/jieba/dict.txt'
frequencies={}
for line in frequency_path.read_text(encoding='utf-8').splitlines():
parts=line.split(' ')
if len(parts)>=2 and parts[1].isdigit():frequencies[parts[0]]=int(parts[1])
spec=importlib.util.spec_from_file_location('crab_dictionary',rtos/'scripts/convert-dictionary.py')
converter=importlib.util.module_from_spec(spec);spec.loader.exec_module(converter)
associations=converter.associations(entries,frequencies)
groups9=['abc','def','ghi','jkl','mno','pqrs','tuv','wxyz'];groups14=['qw','er','ty','ui','op','as','df','gh','jk','l','zx','cv','bn','m']
map9={ch:str(i+2)for i,group in enumerate(groups9)for ch in group}
map14={ch:'0123456789abcd'[i]for i,group in enumerate(groups14)for ch in group}
target=APP/'keyboard/src/main/assets/dictionary.db';target.parent.mkdir(parents=True,exist_ok=True)
temporary=target.with_suffix('.tmp')
if temporary.exists():temporary.unlink()
db=sqlite3.connect(temporary)
db.executescript('PRAGMA page_size=4096; PRAGMA journal_mode=OFF; CREATE TABLE entries(id INTEGER PRIMARY KEY,pinyin TEXT NOT NULL UNIQUE,code9 TEXT NOT NULL,code14 TEXT NOT NULL); CREATE TABLE words(entry INTEGER NOT NULL,ordinal INTEGER NOT NULL,word TEXT NOT NULL,frequency INTEGER NOT NULL,PRIMARY KEY(entry,ordinal)) WITHOUT ROWID;')
for index,(key,words)in enumerate(sorted(entries.items()),1):
db.execute('INSERT INTO entries VALUES(?,?,?,?)',(index,key,''.join(map9[ch]for ch in key),''.join(map14[ch]for ch in key)))
db.executemany('INSERT INTO words VALUES(?,?,?,?)',[(index,ordinal,word,frequencies.get(word,0))for ordinal,word in enumerate(words)])
db.execute('CREATE TABLE associations(prefix TEXT NOT NULL,word TEXT NOT NULL,frequency INTEGER NOT NULL,PRIMARY KEY(prefix,word)) WITHOUT ROWID')
db.executemany('INSERT INTO associations VALUES(?,?,?)',[(prefix,word,frequency)for prefix,rows in associations.items()for word,frequency in rows])
db.executescript('CREATE INDEX entries_code9 ON entries(code9);CREATE INDEX entries_code14 ON entries(code14);ANALYZE;PRAGMA user_version=2;')
db.commit()
restored={}
for key,word in db.execute('SELECT e.pinyin,w.word FROM entries e JOIN words w ON w.entry=e.id ORDER BY e.pinyin,w.ordinal'):restored.setdefault(key,[]).append(word)
assert restored==entries,'Full dictionary round-trip mismatch'
assert db.execute('PRAGMA integrity_check').fetchone()[0]=='ok';db.close();temporary.replace(target)
report={'schema_version':2,'association_prefixes':len(associations),'association_candidates':sum(map(len,associations.values())),'keys':len(entries),'candidates':sum(map(len,entries.values())),'longest_word':max(len(word)for words in entries.values()for word in words),'sha256':hashlib.sha256(target.read_bytes()).hexdigest(),'bytes':target.stat().st_size,'round_trip':True,'source_files':{name:hashlib.sha256(data).hexdigest()for name,data in sorted(files.items())},'jieba_sha256':hashlib.sha256(frequency_path.read_bytes()).hexdigest()}
(APP/'keyboard/src/main/assets/dictionary-manifest.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(json.dumps({k:v for k,v in report.items()if k!='source_files'},ensure_ascii=False))
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--rtos-source',type=pathlib.Path)
args=parser.parse_args();rtos=args.rtos_source
if rtos is None:
rtos=next((p for p in [APP.parent/'com.sydxky.rtos.inputmethod',APP.parent/'RTOS/com.sydxky.rtos.inputmethod']if p.exists()),None)
assert rtos is not None,'Provide the licensed RTOS source with --rtos-source';build(rtos)
+75
查看文件
@@ -0,0 +1,75 @@
"""Capture public screenshots by operating the actual embedded keyboard UI."""
import argparse
from pathlib import Path
import re
import subprocess
import time
import xml.etree.ElementTree as ET
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--serial', required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
def adb(*command):
return subprocess.check_output(['adb', '-s', args.serial, *command], timeout=30)
def tap(value, description=False):
adb('shell', 'uiautomator', 'dump', '/sdcard/crab-capture.xml')
tree = ET.fromstring(adb('exec-out', 'cat', '/sdcard/crab-capture.xml'))
for node in tree.iter('node'):
if node.get('content-desc' if description else 'text') == value:
x1, y1, x2, y2 = map(int, re.findall(r'\d+', node.get('bounds')))
adb('shell', 'input', 'tap', str((x1 + x2)//2), str((y1 + y2)//2))
time.sleep(.22)
return
raise RuntimeError('Missing UI control: ' + value)
def shot(name):
time.sleep(2)
(args.output / (name + '.png')).write_bytes(adb('exec-out', 'screencap', '-p'))
adb('shell', 'input', 'keyevent', '224')
adb('shell', 'wm', 'dismiss-keyguard')
adb('shell', 'am', 'force-stop', 'com.sydxky.crabinput')
adb('shell', 'am', 'start', '-n', 'com.sydxky.crabinput/.MainActivity')
time.sleep(3)
shot('android-home')
tap('demo_text', True)
tap('26键')
shot('android-qwerty')
for letter in 'shi':
tap(letter)
tap('更多候选', True)
shot('android-candidates-16')
tap('返回键盘', True)
# Begin a clean composition by closing and reopening the real editor.
tap('收起键盘', True)
tap('demo_text', True)
tap('14键')
shot('android-fourteen')
for key in ['BN', 'UI', 'GH', 'AS', 'OP']:
tap(key)
tap('更多候选', True)
shot('android-phrases')
tap('你好')
tap('收起键盘', True)
shot('android-result')
adb('shell', 'am', 'start', '-n', 'com.sydxky.crabinput.host/.HostActivity')
time.sleep(2)
tap('fixture_editor', True)
tap('26键')
for letter in 'nihao':
tap(letter)
tap('你好')
tap('收起键盘', True)
shot('android-host')
adb('shell', 'rm', '-f', '/sdcard/crab-capture.xml')
print('Captured 7 real-device UI states; no system IME changes.')
if __name__ == '__main__':
main()
+45
查看文件
@@ -0,0 +1,45 @@
"""Install the release and run embedded-component instrumentation on one explicit device."""
import argparse,hashlib,json,pathlib,re,subprocess,time
APP=pathlib.Path(__file__).resolve().parents[1]
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--serial',required=True);parser.add_argument('--output',type=pathlib.Path,required=True)
parser.add_argument('--background-regression',action='store_true',help='Run native offscreen component checks without launching an Activity or switching the foreground')
args=parser.parse_args();args.output.mkdir(parents=True,exist_ok=True)
def adb(*command,binary=False,timeout=30,retries=3):
last=None
for attempt in range(retries):
result=subprocess.run(['adb','-s',args.serial,*command],stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=timeout)
if result.returncode==0:return result.stdout if binary else result.stdout.decode('utf-8',errors='replace').strip()
last=(result.stdout+result.stderr).decode('utf-8',errors='replace').strip()
if command[0]!='install'and'device' not in last.lower()and'closed' not in last.lower():break
print('Device connection interrupted; retrying this command.',flush=True);time.sleep(1)
raise RuntimeError(last)
apk=APP/'app/build/outputs/apk/release/app-release.apk';test=APP/('keyboard/build/outputs/apk/androidTest/debug/keyboard-debug-androidTest.apk'if args.background_regression else'app/build/outputs/apk/androidTest/release/app-release-androidTest.apk')
assert apk.is_file()and test.is_file()
model=adb('shell','getprop','ro.product.model');sdk=adb('shell','getprop','ro.build.version.sdk')
original=args.output/'original-ime.txt'
if not original.exists():original.write_text(adb('shell','settings','get','secure','default_input_method')+'\n',encoding='utf-8')
host=APP/'host/build/outputs/apk/debug/host-debug.apk'
for path in ([test]if args.background_regression else[apk,test,host]):print(adb('install','-r',str(path),timeout=90),flush=True)
before=adb('shell','settings','get','secure','default_input_method')
print('Default system IME retained; testing the embedded keyboard only.',flush=True)
if not args.background_regression:adb('shell','input','keyevent','224');adb('shell','wm','dismiss-keyguard')
# The instrumentation is bounded short acceptance, not a stress/soak run.
command=['adb','-s',args.serial,'shell','am','instrument','-w','-r']
if args.background_regression:command+=['-e','class','com.sydxky.crabinput.SurfaceRegressionTest']
command+=[('com.sydxky.crabinput.keyboard.test'if args.background_regression else'com.sydxky.crabinput.test')+'/androidx.test.runner.AndroidJUnitRunner']
result=subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=240)
output=result.stdout.decode('utf-8',errors='replace');(args.output/'instrumentation.txt').write_text(output,encoding='utf-8');print(output,flush=True)
expected=4 if args.background_regression else 9
passed=result.returncode==0 and f'OK ({expected} tests)'in output and 'FAILURES!!!'not in output
version=re.search(r"versionName\s+'([^']+)'",(APP/'app/build.gradle').read_text(encoding='utf-8')).group(1)
report={'model':model,'sdk':sdk,'version':version,'release_apk_sha256':hashlib.sha256(apk.read_bytes()).hexdigest(),'instrumentation_tests':expected,'verification_mode':'native_offscreen_component'if args.background_regression else'interactive','interactive_tests_run':not args.background_regression,'component_variant':'debug'if args.background_regression else'release','embedded_component':True,'system_ime_unchanged':adb('shell','settings','get','secure','default_input_method')==before,'passed':passed}
(args.output/'device-validation.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
assert passed and report['system_ime_unchanged'],'See instrumentation.txt; failed/unfinished cases are not accepted'
if not args.background_regression:
adb('shell','am','start','-n','com.sydxky.crabinput/.MainActivity');time.sleep(.5)
(args.output/'home.png').write_bytes(adb('exec-out','screencap','-p',binary=True))
print(json.dumps(report),flush=True)
if __name__=='__main__':main()
+9
查看文件
@@ -0,0 +1,9 @@
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral() }
}
rootProject.name = 'CrabInputWatch'
include ':app'
include ':keyboard'
include ':host'
+18 -1
查看文件
@@ -24,7 +24,23 @@ $targets = if ($QuickAppOnly) {
} }
$originalPath = $env:Path $originalPath = $env:Path
Push-Location $ProjectRoot $buildRoot = $ProjectRoot
$mappedDrive = $null
# Bundled pngquant still uses MAX_PATH; keep deeply generated apps buildable.
if ($IsWindows -and $ProjectRoot.Length + $manifest.package.Length + 150 -gt 245) {
$subst = Join-Path $env:SystemRoot 'System32\subst.exe'
foreach ($letter in @('Z','Y','X','W','V','U','T','S','R')) {
if (Test-Path -LiteralPath ($letter + ':\')) { continue }
& $subst ($letter + ':') $ProjectRoot 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
$mappedDrive = $letter + ':'
$buildRoot = $mappedDrive + '\'
break
}
}
if (-not $mappedDrive) { throw 'No temporary drive available for the vendor MAX_PATH workaround; use a shorter ProjectRoot' }
}
Push-Location $buildRoot
try { try {
$env:Path = "$toolBin;$originalPath" $env:Path = "$toolBin;$originalPath"
$availableDevices = @(& $gx list device) $availableDevices = @(& $gx list device)
@@ -77,5 +93,6 @@ try {
} }
} finally { } finally {
Pop-Location Pop-Location
if ($mappedDrive) { & $subst $mappedDrive /D | Out-Null }
$env:Path = $originalPath $env:Path = $originalPath
} }
+4
查看文件
@@ -20,3 +20,7 @@
- 使用实际 Inspector 树检查按钮和文字是否超出任一父容器;另检查屏幕边界、边缘黑色缺口、四候选与翻页/底栏的整体显示。几何与截图同时通过才可用于最终预览。 - 使用实际 Inspector 树检查按钮和文字是否超出任一父容器;另检查屏幕边界、边缘黑色缺口、四候选与翻页/底栏的整体显示。几何与截图同时通过才可用于最终预览。
浏览器1.1.0同步使用浅色主界面和同一个键盘维护源。14键使用独立编码索引,候选仍来自同一完整词库;账号、私密输入和草稿持久化规则保持有效。 浏览器1.1.0同步使用浅色主界面和同一个键盘维护源。14键使用独立编码索引,候选仍来自同一完整词库;账号、私密输入和草稿持久化规则保持有效。
## 1.2.0候选网格
完整候选默认4列4行、单字16个一页,隐藏注音及该页拼音栏。2~4字词组占两格,长词占整行并按长度增加行高,单页最多四行单位。不得省略或截断文字,不丢候选;每页只将当前可见项放入响应式数据。圆屏网格保持中段安全宽度,底部返回、退格和完成使用原有矢量来源图标。
+1 -1
查看文件
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 herry Copyright (c) 2026 sydxky
Copyright (c) 2026 kqakqakqa Copyright (c) 2026 kqakqakqa
Copyright (c) 2026 crabKun Copyright (c) 2026 crabKun
+2 -2
查看文件
@@ -1,6 +1,6 @@
# 蟹蟹输入 RTOS适配改版 # 蟹蟹输入 RTOS适配改版
本项目为基于[CrabKeyboard / 蟹蟹输入](https://github.com/crabPangXie/crabKeyboard)的独立RTOS适配改版,由herry进行Glyphix页面、离线词库索引、九宫/14键消歧、标准QWERTY、候选分页及生命周期适配。不是上游原版,也不表示上游作者、华为、OPPO或Glyphix对本改版作出背书。 本项目为基于[CrabKeyboard / 蟹蟹输入](https://github.com/crabPangXie/crabKeyboard)的独立RTOS适配改版,由sydxky进行Glyphix页面、离线词库索引、九宫/14键消歧、标准QWERTY、候选分页及生命周期适配。不是上游原版,也不表示上游作者、华为、OPPO或Glyphix对本改版作出背书。
上游固定提交:`93d36b5002dee80a3d253b1ec7ec9e29800a104c`。保留原作者kqakqakqa、crabKun的版权声明、MIT许可证及原始词库/读取器。RTOS自研适配部分以根目录[MIT许可证](./LICENSE)发布,**允许商用、修改、再分发和闭源集成**,须保留适用版权及许可全文。 上游固定提交:`93d36b5002dee80a3d253b1ec7ec9e29800a104c`。保留原作者kqakqakqa、crabKun的版权声明、MIT许可证及原始词库/读取器。RTOS自研适配部分以根目录[MIT许可证](./LICENSE)发布,**允许商用、修改、再分发和闭源集成**,须保留适用版权及许可全文。
@@ -14,4 +14,4 @@
14键参考公开演示中的相邻字母分组,自行实现离线消歧;不包含华为内部模型或固件代码。Glyphix/厂商SDK与运行时不包含在本项目MIT授权中,也不随源码分发,使用者需自行取得适用的厂商授权。 14键参考公开演示中的相邻字母分组,自行实现离线消歧;不包含华为内部模型或固件代码。Glyphix/厂商SDK与运行时不包含在本项目MIT授权中,也不随源码分发,使用者需自行取得适用的厂商授权。
公开仓库:<https://git2.sydxky.cn/herry/crab-input-rtos>。匿名问题与建议:<https://git2.sydxky.cn/feedback/herry/crab-input-rtos>。 公开仓库:<https://git2.sydxky.cn/sydxky/crab-input-rtos>。匿名问题与建议:<https://git2.sydxky.cn/feedback/sydxky/crab-input-rtos>。
+2
查看文件
@@ -4,6 +4,8 @@
正在编辑的文本仅在本次页面的内存中。主动“保存草稿”后,普通文本保存于本应用本地存储,供下次启动恢复;可清空全文后重新保存以删除旧草稿,卸载应用也会清除该存储。私密输入不提供保存、不参与学习,切换其他应用或销毁页面时清空编辑状态。 正在编辑的文本仅在本次页面的内存中。主动“保存草稿”后,普通文本保存于本应用本地存储,供下次启动恢复;可清空全文后重新保存以删除旧草稿,卸载应用也会清除该存储。私密输入不提供保存、不参与学习,切换其他应用或销毁页面时清空编辑状态。
离线上下文联想仅使用本次编辑光标前最多4个连续汉字,不跨标点或非中文字符,不保存联想上下文;密码/私密输入不参与。续写索引来自已附许可的现有词库与本项目自编的少量常用短语,不增加网络请求。
候选学习默认关闭。开启后仅在本应用保存最多 100 组编码、每组三个主动选择的真实候选;不会保存全文。在编辑页可关闭学习或“清除学习”。关闭学习不会自动删除旧记录,可通过清除按钮删除。 候选学习默认关闭。开启后仅在本应用保存最多 100 组编码、每组三个主动选择的真实候选;不会保存全文。在编辑页可关闭学习或“清除学习”。关闭学习不会自动删除旧记录,可通过清除按钮删除。
嵌入其他 Glyphix 应用后,键盘数据保存在该宿主自己的隔离空间。完成输入会将文本回填给宿主;宿主后续的网络行为适用其自身说明。腕上浏览的兼容模式会在明确告知后把网页输入交给服务器,详见其独立隐私政策。 嵌入其他 Glyphix 应用后,键盘数据保存在该宿主自己的隔离空间。完成输入会将文本回填给宿主;宿主后续的网络行为适用其自身说明。腕上浏览的兼容模式会在明确告知后把网页输入交给服务器,详见其独立隐私政策。
+19 -5
查看文件
@@ -1,8 +1,16 @@
# 蟹蟹输入 / Glyphix RTOS # 蟹蟹输入 / Glyphix RTOS
**蟹蟹输入 RTOS适配改版**:基于CrabKeyboard的独立改版,RTOS适配代码采用[MIT协议](./LICENSE),允许商用、修改及再分发,须保留版权与许可。上游与第三方署名见[NOTICE.md](./NOTICE.md)。公开源码:<https://git2.sydxky.cn/herry/crab-input-rtos>;免注册[匿名反馈](https://git2.sydxky.cn/feedback/herry/crab-input-rtos)。 **蟹蟹输入 RTOS适配改版**:基于CrabKeyboard的独立改版,RTOS适配代码采用[MIT协议](./LICENSE),允许商用、修改及再分发,须保留版权与许可。上游与第三方署名见[NOTICE.md](./NOTICE.md)。公开源码:<https://git2.sydxky.cn/sydxky/crab-input-rtos>;免注册[匿名反馈](https://git2.sydxky.cn/feedback/sydxky/crab-input-rtos)。
独立应用 `com.sydxky.rtos.inputmethod`,版本 `1.1.0 / 10100`。基于 [CrabKeyboard](https://github.com/crabPangXie/crabKeyboard) 的 MIT 全量词库和拼音前缀消费方式,重写为 Glyphix `.ux` 页面;不依赖 HarmonyOS HML、Android 输入法服务、DOM 或网络。 独立应用 `com.sydxky.rtos.inputmethod`,版本 `1.2.4 / 10204`。基于 [CrabKeyboard](https://github.com/crabPangXie/crabKeyboard) 的 MIT 全量词库和拼音前缀消费方式,重写为 Glyphix `.ux` 页面;不依赖 HarmonyOS HML、Android 输入法服务、DOM 或网络。
1.2.4 将字体字符光标换成独立1像素细线,光标紧贴前后文字,保留真实空格与Unicode边界;扩大26键,逐排适配圆屏安全区域。英文模式按钮明确显示下一步“大写/小写”,切换后实际输入大小写一致。验证范围见[本版验收](./VALIDATION_1.2.4.md)。
1.2.3 新增未完成拼音补全、离线上下文续写和词频排序;查询期间保留候选,避免先清空再重画。验证范围见[本版验收](./VALIDATION_1.2.3.md)。
1.2.2 修复文字与键盘之间的多余空隙,圆屏按键上移后同步检查安全边界;本版验证范围见[间距修复验收](./VALIDATION_1.2.2.md)。
1.2.0 完整候选重做为 **4×4 网格**:单字每页16个,2~4字词组占两格,长词占整行并可增加行高;保持原有顺序,最后一页可达,不显示候选拼音。圆/方普通及QuickApp四配置已通过84项原生操作、84项布局及48个视觉状态,见[验收记录](./VALIDATION_1.2.0.md)。同源新增[Android应用内键盘组件](../android/README.md),独立包名、无需联网,接入应用的输入框自动弹出,保持系统默认输入法。
1.0.1 按用户选择重做为浅色手机输入法风格:浅灰底、白色大按键、深色候选和蓝色主操作。候选栏与模式栏减少色块,英语和数字模式隐藏无用候选区;首页只突出一个输入主按钮。设计规范见 [DESIGN.md](./DESIGN.md)。 1.0.1 按用户选择重做为浅色手机输入法风格:浅灰底、白色大按键、深色候选和蓝色主操作。候选栏与模式栏减少色块,英语和数字模式隐藏无用候选区;首页只突出一个输入主按钮。设计规范见 [DESIGN.md](./DESIGN.md)。
@@ -14,8 +22,8 @@
- 点击首页文字区或“开始输入”即打开满屏键盘。完成回填;取消保留原文。草稿需主动保存,仅保存在本应用。 - 点击首页文字区或“开始输入”即打开满屏键盘。完成回填;取消保留原文。草稿需主动保存,仅保存在本应用。
- 默认九宫大按键。“26键”切到标准 QWERTY 全拼,`v` 表示 `ü`;“九宫”切回九宫。“14键”直接进入双字母模式。连续点击对应字母组,选择候选后只消费匹配部分,剩余编码继续查词。存在未选词的九宫或14键编码时禁止切换模式/完成,避免丢字或把编码当文字提交。 - 默认九宫大按键。“26键”切到标准 QWERTY 全拼,`v` 表示 `ü`;“九宫”切回九宫。“14键”直接进入双字母模式。连续点击对应字母组,选择候选后只消费匹配部分,剩余编码继续查词。存在未选词的九宫或14键编码时禁止切换模式/完成,避免丢字或把编码当文字提交。
- 第一行显示三个候选。点击右侧下箭头按每页四项查看全部匹配候选,左右箭头翻页,不使用原项目扩展列表的 100 项截断。拼音候选保留每组原始顺序;九宫和14键歧义通过本地 jieba 0.42.1 词频排序,学习偏好可优先排序。 - 第一行显示三个候选。点击右侧下箭头按4×4自适应分页查看全部匹配候选,左右箭头翻页,不使用原项目扩展列表的 100 项截断。完整拼音、九宫和14键先匹配完整编码,再提供未完成拼音补全和较短前缀;通过本地 jieba 0.42.1 词频及当前光标前的中文上下文排序,学习偏好可优先排序。
- 支持英文大小写、数字、完整可打印 ASCII 标点与常用中文标点。符号分两组大按键,通过“下一组符号”切换。网址额外提供 `https://`、`.com`。 - 点“abc”进入英文;再点“大写”切换大写,点“小写”恢复小写,字母键标签与输入字符同步。支持数字、完整可打印 ASCII 标点与常用中文标点。符号分两组大按键,通过“下一组符号”切换。网址额外提供 `https://`、`.com`。
- 点击文本预览或铅笔图标移动光标、换行、上屏全拼、清空全文。Unicode 字符按码点计数和删除,避免拆坏 emoji。清空全文需第二次点击确认;长按退格图标连续删除,松开/隐藏/销毁停止。勾号完成,叉号取消,键盘图标返回输入。 - 点击文本预览或铅笔图标移动光标、换行、上屏全拼、清空全文。Unicode 字符按码点计数和删除,避免拆坏 emoji。清空全文需第二次点击确认;长按退格图标连续删除,松开/隐藏/销毁停止。勾号完成,叉号取消,键盘图标返回输入。
- 学习默认关闭,可开启或清除,仅保存最多 100 组、每组三个已选择的真实候选,不记录整段正文。私密输入始终遮盖、不学习、不保存;切到其他应用时清空私密编辑状态。 - 学习默认关闭,可开启或清除,仅保存最多 100 组、每组三个已选择的真实候选,不记录整段正文。私密输入始终遮盖、不学习、不保存;切到其他应用时清空私密编辑状态。
@@ -60,8 +68,14 @@ python .\tests\visual.py --work '<另一个临时目录>' --output '<原生证
## 内存与词库 ## 内存与词库
`convert-dictionary.py` 将原始 V5 trie 无损转换为 **6779** 个编码分片(九宫/全拼4194个、14键2585个),最大分片 **116,686** 字节。九宫/全拼使用四位前缀,14键使用三位前缀;短编码单独索引。解码缓存限制为四项且 JSON 文本合计不超过 90,000 字符,大分片不缓存。仅当前页的四个候选进入展开列表,所有候选对象不放进响应式状态。转换器分别回读两种索引,验证与原始索引完全相同;不改写原始字典。 `convert-dictionary.py` 将原始 V5 trie 无损转换为 **6779** 个编码分片(九宫/全拼4194个、14键2585个),最大分片 **116,686** 字节。九宫/全拼使用四位前缀,14键使用三位前缀;短编码单独索引。解码缓存限制为四项且 JSON 文本合计不超过 90,000 字符,大分片不缓存。仅当前页最多16个候选进入展开网格,所有候选对象不放进响应式状态。转换器分别回读两种索引,验证与原始索引完全相同;不改写原始字典。
初期两位编码分片曾触发原生模拟器内存回收,已改为四位编码分片。验收保持既有 `--memory-profile 8192`,没有通过增大模拟器内存规避问题。模拟器耗时不能替代真机帧率、延迟或功耗结论。 初期两位编码分片曾触发原生模拟器内存回收,已改为四位编码分片。验收保持既有 `--memory-profile 8192`,没有通过增大模拟器内存规避问题。模拟器耗时不能替代真机帧率、延迟或功耗结论。
MIT 许可副本也随运行包保留。jieba 词频只用于歧义候选排序,不联网、不分词上传。官方公开 API 不足以实现全局输入法安装;本轮仍按用户确认的模拟器与提交包范围交付,真机安装与平台审核单独处理。 MIT 许可副本也随运行包保留。jieba 词频只用于歧义候选排序,不联网、不分词上传。官方公开 API 不足以实现全局输入法安装;本轮仍按用户确认的模拟器与提交包范围交付,真机安装与平台审核单独处理。
## 2026-11-14 内部组件接入与持续同步
在 1.2.3 最新引擎和上下文联想基础上,内部源抽出 `src/components/CrabInput/index.ux`,`keyboard/index.ux` 变为兼容封装。组件采用 config 属性与 complete/cancel 事件;宿主不再维护第二套键盘。腕上星语 1.4.1 直接挂载组件,其他宿主仍可继续调用旧路由。私密输入默认遮盖;宿主显式 `revealSecret:true` 时明文显示,但不学习、不持久化。
安装脚本支持 `node scripts/install.mjs <宿主> --watch` 持续同步及 `--check` 只读校验;涵盖组件、封装、引擎、主题、词库、图标和第三方许可,生成来源 SHA-256 清单。不变文件不重写,旧的自有分片会清理,其他宿主文件保留。原始词库或转换器更新会自动重新生成,生成中禁止通过校验。`tests/sync.test.mjs` 实际验证监听后的源码/词库更新、删除及哈希拒绝。完整约定见 [腕上星语接入文档](../com.sydxky.rtos.aichat/INPUT_COMPONENT.md)。这次没有发布新的 Git2 输入法版本。
@@ -0,0 +1,21 @@
# 蟹蟹输入 RTOS 1.2.0 验收
日期:2026-11-14,版本 1.2.0 / 10200,包名 `com.sydxky.rtos.inputmethod`。
完整候选改为 4×4 网格。单字占一列,每页最多 16 项;词组按长度扩宽或增加行高,保留原顺序与全部候选,展开页不显示拼音。查询“shi”时实际 90 项、自适应分为 7 页。QWERTY 和 14 键布局维持原有正确顺序。
| 验证 | 结果 |
| --- | --- |
| 引擎逻辑 | 44 项断言通过,包含单字网格、混合词组、长词、分页完整性 |
| UX 脚本 | 32 项断言通过 |
| 原始词库 | 104,837 组索引、163,238 候选,无损转换 |
| 构建 | 普通/QuickApp 圆屏 466×466、方屏 390×450 四配置通过 |
| 真实原生操作 | 84/84 通过 |
| 操作后布局 | 84/84 通过 |
| 视觉状态 | 48/48 通过,包括候选网格与词组候选 |
厂商工具 `gx v0.10.1 / 3ed38835`,内存配置保持 8192 KiB。使用真实 Inspector、原生控件树及截图,不用浏览器绘制页面代替运行时验收。当前页至多 16 项进入响应式状态,分页元数据不进入深度响应式遍历,页面销毁时清理候选及定时任务。
QuickApp 两屏交付各包含 `bundle.pkg`、`icon.png`、`manifest.json`。实际截图、汇总报告和产物 SHA-256 另附发行版。Android 应用内组件验证见同仓库 Android 文档。
按用户确认,本平台仅完成模拟器及提交包验收;RTOS 真机安装、目标固件长期运行、性能/功耗与商店审核尚未验收。
@@ -0,0 +1,11 @@
# 蟹蟹输入 RTOS 1.2.2 间距修复
2026-11-14,包名 `com.sydxky.rtos.inputmethod`,版本 `1.2.2 / 10202`。
键盘主体从纵向居中改为紧接文字与编码区排列,完整候选从上方开始排列。保留可见的中文/符号候选栏;英文、数字模式不预留候选栏,也不把剩余高度分配到文字与键盘之间。圆屏按键区宽度由376调到368逻辑像素,避免上移后英文首排边角越出圆形安全边界;工具页按钮减少1逻辑像素高度,消除末排在父容器内的轻微裁切。
使用厂商 `gx v0.10.1 / 3ed38835`、8192 KiB内存。44项引擎与32项UX脚本断言通过,圆/方普通及QuickApp四配置构建通过。当前普通圆屏和方屏共24个原生视觉状态通过,包括九宫、全拼、14键、英文、数字、两组符号、候选与工具页;新增控件树间距检查,英文/数字编码区到按键区实际为0像素。逐层父容器裁切、圆屏安全边界与背景边缘检查通过。
QuickApp原生视觉检查遇到已有其他应用占用Inspector端口,未接管或终止其他进程;本次不将四配置构建等同于四配置全部原生验收。没有重跑1.2.0的84项原生交互测试,历史结果不冒充本版验收。
RTOS真机、长期性能/功耗与平台审核未验收。Android独立记录见其 `VALIDATION_1.2.2.md`。
@@ -0,0 +1,7 @@
# RTOS 1.2.3 联想和刷新验收
2026-11-14,版本1.2.3/10203。完整匹配、未完成拼音补全、较短前缀分层,结合词频与光标前中文上下文排序。续写最多8项、上下文最多4个连续汉字;预测分片最大7163字节,沿用4分片/90000字符缓存预算,原始104,837组索引及163,238候选保留。隐私输入不提供上下文,原本可选的主动候选学习保持不变。
53项引擎和42项UX脚本断言通过,包含三模式补全、你好后续写世界、空格及换行结束联想且不自动采纳、候选加载期间不清空、不允许旧候选误提交、隐藏后的异步隔离和密码上下文隔离。厂商gx v0.10.1/3ed38835、8192KiB内存,普通/QuickApp圆方四配置构建通过。
本次原生模拟器启动被已占用的Inspector14201拒绝,未接管或终止他人模拟器,未把历史24视觉状态/84交互结果记为本版结果。RTOS真机、原生视觉、长期功耗和商店审核尚未验收。
@@ -0,0 +1,11 @@
# RTOS 1.2.4 光标、26键及大小写验收
2026-11-14,版本1.2.4/10204。光标改为独立1像素细线,前后文字使用连续布局,保留真实空格、Unicode码点及私密遮蔽;长文本按预览宽度预算截取,使光标留在预览内。26键使用明确的10/9/7三排,扩大英文按键并按圆屏高度收放各排宽度。英文切换按钮显示下一步“大写/小写”,按当前状态规范提交字符,避免旧回调大小写错配。
59项引擎和49项UX脚本断言通过。厂商gx v0.10.1/3ed38835、8192KiB内存,普通/QuickApp圆方四配置生产构建通过。104,837组索引、163,238候选完整保留,沿用1.2.3离线补全、上下文续词、候选稳定刷新及密码隔离。
新增tests/console-native.py,使用自行启动、不启用Inspector的独立原生模拟器。将生产components/CrabInput/index.ux逐字节复制到自动运行的测试工程,仅增加测试入口并改变测试工程路由;报告记录生产源码SHA-256及各配置测试包摘要。没有连接或接管其他任务的Inspector14201。
圆屏466×466、方屏390×450的普通/QuickApp四配置各24个状态,共96个原生状态通过。覆盖白蓝/黑绿两套主题,中文、词组、英文、混合emoji、开头/中间/结尾、长文及私密遮蔽光标;实测前文字—光标—后文字额外布局间距均为0px,光标宽1px。每配置检查拼音/英文与大小写的26个按键边界,圆屏逐个检查按键四角不超出半径231px安全区。四配置均通过@system.test真实点击切换并输入qQq,不以直接调用press替代点击。
96张截图为上述原生测试工程的真实画面,未绘制替代UI;它们证明生产键盘组件在测试入口中的布局,不代表完整发布包的全套端到端验收。结果、原始日志、截图与源码摘要随验收ZIP提供。本版没有重跑原84项前后台交互;RTOS真机、固件接入、长期性能/功耗和商店审核未验收。
@@ -7,6 +7,40 @@ T9 = {letter: str(i + 2) for i, group in enumerate(GROUPS) for letter in group}
GROUPS14 = ['qw','er','ty','ui','op','as','df','gh','jk','l','zx','cv','bn','m'] GROUPS14 = ['qw','er','ty','ui','op','as','df','gh','jk','l','zx','cv','bn','m']
KEY14 = {letter: '0123456789abcd'[i] for i, group in enumerate(GROUPS14) for letter in group} KEY14 = {letter: '0123456789abcd'[i] for i, group in enumerate(GROUPS14) for letter in group}
def predictive_bucket(value):
result=0
for ch in value:result=(result*31+ord(ch))&0xffff
return format(result%256,'02x')
def associations(entries,frequencies):
"""Derive bounded word continuations from licensed phrases; no user corpus."""
result={}
for word in {word for words in entries.values()for word in words}:
frequency=frequencies.get(word,0)
if frequency<20 or not re.fullmatch(r'[\u3400-\u9fff]{2,12}',word):continue
for cut in range(1,min(4,len(word)-1)+1):
prefix,suffix=word[:cut],word[cut:]
result.setdefault(prefix,{})[suffix]=round(frequency/(1+.25*len(suffix)))
# Small, original conversational defaults supplement dictionary word splits.
seeds=json.loads((APP/'src/common/association-seeds.json').read_text(encoding='utf-8'))
for prefix,words in seeds.items():
for rank,word in enumerate(words):result.setdefault(prefix,{})[word]=200000-rank*10000
return {prefix:sorted(words.items(),key=lambda x:(-x[1],x[0]))[:8]for prefix,words in sorted(result.items())}
def completions(entries,frequencies):
result={}
for key,words in entries.items():
for mode,code in [('py',key),('t9',''.join(T9[ch]for ch in key)),('14',''.join(KEY14[ch]for ch in key))]:
for length in range(2,min(3 if mode!='14'else 2,len(code)-1)+1):
if len(code)-length>4:continue
target=result.setdefault(mode+':'+code[:length],{})
for ordinal,word in enumerate(words):
frequency=frequencies.get(word,0)
if frequency<20:continue
value=[word,key,frequency,ordinal]
if word not in target or target[word][2]<frequency:target[word]=value
return {prefix:sorted(words.values(),key=lambda x:(-x[2],len(x[1]),x[3],x[0]))[:12]for prefix,words in sorted(result.items())}
def from94(value): def from94(value):
number = 0 number = 0
for ch in value: for ch in value:
@@ -15,6 +49,16 @@ def from94(value):
return number return number
def convert(source, target): def convert(source, target):
target.parent.mkdir(parents=True, exist_ok=True)
assert target.resolve().is_relative_to((APP/'src/assets').resolve())
marker=target.parent/('.'+target.name+'.generating')
with marker.open('x',encoding='ascii') as handle:handle.write('generating')
try:
_convert(source,target)
finally:
marker.unlink()
def _convert(source, target):
files = {p.name: p.read_bytes() for p in source.glob('*.dat')} files = {p.name: p.read_bytes() for p in source.glob('*.dat')}
frequency_path=APP/'third_party/jieba/dict.txt' frequency_path=APP/'third_party/jieba/dict.txt'
frequencies={} frequencies={}
@@ -48,8 +92,8 @@ def convert(source, target):
# Only replace files listed in the previous generated manifest. # Only replace files listed in the previous generated manifest.
if (target/'meta.json').exists(): if (target/'meta.json').exists():
previous=json.loads((target/'meta.json').read_text(encoding='utf-8')) previous=json.loads((target/'meta.json').read_text(encoding='utf-8'))
for name in previous['shards']: for name in [*previous['shards'],*previous.get('association_shards',[]),*previous.get('completion_shards',[])]:
assert re.fullmatch(r'(?:short|s?[0-9]+|ds?[0-9a-d]+)',name) assert re.fullmatch(r'(?:short|s?[0-9]+|ds?[0-9a-d]+|[ac][0-9a-f]{2})',name)
old=target/(name+'.json') old=target/(name+'.json')
if name not in shards and old.exists():old.unlink() if name not in shards and old.exists():old.unlink()
for name, content in shards.items(): for name, content in shards.items():
@@ -57,18 +101,27 @@ def convert(source, target):
output=target/(name+'.json') output=target/(name+'.json')
data=json.dumps({'entries':content,'frequencies':weights}, ensure_ascii=False, separators=(',', ':')).encode('utf-8') data=json.dumps({'entries':content,'frequencies':weights}, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
if not output.exists() or output.read_bytes()!=data:output.write_bytes(data) if not output.exists() or output.read_bytes()!=data:output.write_bytes(data)
association_shards={};completion_shards={}
for prefix,items in associations(entries,frequencies).items():association_shards.setdefault('a'+predictive_bucket(prefix),{})[prefix]=items
for prefix,items in completions(entries,frequencies).items():completion_shards.setdefault('c'+predictive_bucket(prefix),{})[prefix]=items
for name,content in {**association_shards,**completion_shards}.items():
(target/(name+'.json')).write_text(json.dumps(content,ensure_ascii=False,separators=(',',':')),encoding='utf-8',newline='\n')
# Every original candidate remains accessible, in original per-key order. # Every original candidate remains accessible, in original per-key order.
restored = {};restored14={} restored = {};restored14={}
for name in shards: for name in shards:
(restored14 if name.startswith('d')else restored).update(json.loads((target/(name+'.json')).read_text(encoding='utf-8'))['entries']) (restored14 if name.startswith('d')else restored).update(json.loads((target/(name+'.json')).read_text(encoding='utf-8'))['entries'])
assert restored == entries and restored14 == entries assert restored == entries and restored14 == entries
report = dict(upstream='https://github.com/crabPangXie/crabKeyboard', report = dict(association_seed_sha256=hashlib.sha256((APP/'src/common/association-seeds.json').read_bytes()).hexdigest(),
generator_sha256=hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest(),
upstream='https://github.com/crabPangXie/crabKeyboard',
commit='93d36b5002dee80a3d253b1ec7ec9e29800a104c', commit='93d36b5002dee80a3d253b1ec7ec9e29800a104c',
variant='pinyinDictV5_20260416', keys=len(entries), variant='pinyinDictV5_20260416', keys=len(entries),
candidates=sum(map(len, entries.values())), shards=sorted(shards), candidates=sum(map(len, entries.values())), shards=sorted(shards),
t9_shards=sum(not name.startswith('d')for name in shards), t9_shards=sum(not name.startswith('d')for name in shards),
key14_shards=sum(name.startswith('d')for name in shards),key14_groups=GROUPS14,key14_prefix_length=3, key14_shards=sum(name.startswith('d')for name in shards),key14_groups=GROUPS14,key14_prefix_length=3,
largest_shard_bytes=max((target/(name+'.json')).stat().st_size for name in shards), largest_shard_bytes=max((target/(name+'.json')).stat().st_size for name in shards),
association_shards=sorted(association_shards),completion_shards=sorted(completion_shards),
predictive_largest_shard_bytes=max((target/(name+'.json')).stat().st_size for name in [*association_shards,*completion_shards]),
source_files={name:hashlib.sha256(data).hexdigest() for name,data in sorted(files.items())}) source_files={name:hashlib.sha256(data).hexdigest() for name,data in sorted(files.items())})
if frequency_path.exists():report['t9_frequency_source']={'project':'jieba 0.42.1','license':'MIT','sha256':hashlib.sha256(frequency_path.read_bytes()).hexdigest()} if frequency_path.exists():report['t9_frequency_source']={'project':'jieba 0.42.1','license':'MIT','sha256':hashlib.sha256(frequency_path.read_bytes()).hexdigest()}
output=target/'meta.json';data=(json.dumps(report, ensure_ascii=False, indent=2)+'\n').encode('utf-8') output=target/'meta.json';data=(json.dumps(report, ensure_ascii=False, indent=2)+'\n').encode('utf-8')
+132 -19
查看文件
@@ -1,26 +1,139 @@
// Copy the canonical keyboard into another Glyphix sandbox; no cross-app data. // The canonical component is maintained here; hosts consume generated copies.
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import crypto from 'node:crypto'
import {spawnSync} from 'node:child_process'
import {fileURLToPath} from 'node:url' import {fileURLToPath} from 'node:url'
const app=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..') const APP=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..')
const target=path.resolve(process.argv[2]||'') const RECEIPT='src/assets/crab-input-source.json'
if(!process.argv[2]||!fs.existsSync(path.join(target,'src/manifest.json')))throw Error('Usage: node scripts/install.mjs <Glyphix project>') const ROOTS=['keyboard','components/CrabInput','assets/ime-icons']
const NOTICES={'crab-keyboard-license.txt':'third_party/crabKeyboard/LICENSE','jieba-license.txt':'third_party/jieba/LICENSE','crab-input-license.txt':'LICENSE','crab-input-notice.txt':'NOTICE.md','remix-icon-license.txt':'third_party/remix-icon/LICENSE'}
const digest=bytes=>crypto.createHash('sha256').update(bytes).digest('hex')
const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'))
function dictionaryReady(source){
if(fs.existsSync(path.join(source,'src/assets/.ime-dict.generating')))throw Error('Dictionary generation is running; retry after it completes')
const converter=path.join(source,'scripts/convert-dictionary.py'),metaFile=path.join(source,'src/assets/ime-dict/meta.json')
if(!fs.existsSync(converter))return fs.existsSync(metaFile)
if(!fs.existsSync(metaFile))return false
const meta=readJson(metaFile)
const seeds=path.join(source,'src/common/association-seeds.json')
if(fs.existsSync(seeds)&&meta.association_seed_sha256!==digest(fs.readFileSync(seeds)))return false
if(meta.generator_sha256!==digest(fs.readFileSync(converter)))return false
const rawDirectory=path.join(source,'third_party/crabKeyboard/pinyinDictV5_20260416')
if(JSON.stringify(fs.readdirSync(rawDirectory).filter(name=>name.endsWith('.dat')).sort())!==JSON.stringify(Object.keys(meta.source_files||{}).sort()))return false
for(const [name,hash]of Object.entries(meta.source_files||{})){
if(!/^key[0-9]+\.dat$|^value[0-9]+\.dat$/.test(name))throw Error('Invalid dictionary input filename')
const file=path.join(source,'third_party/crabKeyboard/pinyinDictV5_20260416',name)
if(!fs.existsSync(file)||digest(fs.readFileSync(file))!==hash)return false
}
const frequency=path.join(source,'third_party/jieba/dict.txt')
return fs.existsSync(frequency)&&digest(fs.readFileSync(frequency))===meta.t9_frequency_source?.sha256
}
function prepareDictionary(source){
if(dictionaryReady(source))return
const result=spawnSync('python',[path.join(source,'scripts/convert-dictionary.py')],{stdio:'inherit',windowsHide:true})
if(result.status!==0||!dictionaryReady(source))throw Error('Generate the canonical dictionary before synchronizing')
}
const managed=name=>/^src\/(keyboard\/|components\/CrabInput\/|common\/ime-[a-z0-9-]+\.js$|assets\/ime-(dict|icons)\/|assets\/(crab-keyboard|jieba|crab-input|remix-icon)-(license|notice)\.txt$)/.test(name)&&!name.split('/').some(part=>part==='..'||part==='.')&&!name.includes('\\')
function walk(directory){return fs.readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name)).flatMap(entry=>{
if(entry.isSymbolicLink())throw Error('Input source must not contain symbolic links')
return entry.isDirectory()?walk(path.join(directory,entry.name)).map(name=>entry.name+'/'+name):[entry.name]
})}
function includesRoute(target){
const file=path.join(target,'input-component.json')
if(!fs.existsSync(file))return true
const config=readJson(file)
if(config.schema!==1||!['component','route'].includes(config.integration))throw Error('Invalid input-component.json')
return config.integration==='route'
}
export function snapshot(source=APP,includeRoute=true){
if(!dictionaryReady(source))throw Error('Dictionary inputs changed; regenerate before checking')
const files={}
const add=(name,from)=>{const bytes=fs.readFileSync(from);files[name]={size:bytes.length,sha256:digest(bytes)}}
for(const root of ROOTS.filter(root=>includeRoute||root!=='keyboard'))for(const file of walk(path.join(source,'src',root)))add('src/'+root+'/'+file,path.join(source,'src',root,file))
for(const file of fs.readdirSync(path.join(source,'src/common')).filter(name=>/^ime-[a-z0-9-]+\.js$/.test(name)))add('src/common/'+file,path.join(source,'src/common',file))
const meta=readJson(path.join(source,'src/assets/ime-dict/meta.json'))
if(!Array.isArray(meta.shards)||!meta.shards.length||new Set(meta.shards).size!==meta.shards.length)throw Error('Dictionary is not generated or has invalid metadata')
for(const name of ['meta',...meta.shards,...(meta.association_shards||[]),...(meta.completion_shards||[])]){
if(name!=='meta'&&!/^(short|s?[0-9]+|ds?[0-9a-d]+|[ac][0-9a-f]{2})$/.test(name))throw Error('Invalid generated shard name')
add('src/assets/ime-dict/'+name+'.json',path.join(source,'src/assets/ime-dict',name+'.json'))
}
for(const [name,file]of Object.entries(NOTICES))add('src/assets/'+name,path.join(source,file))
const sorted=Object.fromEntries(Object.entries(files).sort(([a],[b])=>a.localeCompare(b)))
const body={schema:1,component:'CrabInput',apiVersion:1,integration:includeRoute?'route':'component',inputVersion:readJson(path.join(source,'src/manifest.json')).versionName,files:sorted}
if(!dictionaryReady(source))throw Error('Dictionary changed during snapshot')
return {...body,sourceHash:digest(JSON.stringify(body))}
}
function sourceFile(source,name){
if(NOTICES[path.posix.basename(name)])return path.join(source,NOTICES[path.posix.basename(name)])
return path.join(source,name)
}
function targetProject(target,source){
if(path.resolve(target)===path.resolve(source)||!fs.existsSync(path.join(target,'src/manifest.json')))throw Error('Target must be a separate Glyphix host project')
}
export function checkInput(target,source=APP){
targetProject(target,source)
const includeRoute=includesRoute(target),current=snapshot(source,includeRoute),receipt=path.join(target,RECEIPT)
if(!fs.existsSync(receipt)||JSON.stringify(readJson(receipt))!==JSON.stringify(current))throw Error('Input source changed; run install.mjs to synchronize before building')
for(const [name,info]of Object.entries(current.files)){
const file=path.join(target,name)
if(!fs.existsSync(file)||digest(fs.readFileSync(file))!==info.sha256)throw Error('Input host copy differs: '+name)
}
if(snapshot(source,includeRoute).sourceHash!==current.sourceHash)throw Error('Input source changed during verification; retry after edits settle')
return current
}
export function syncInput(target,source=APP){
targetProject(target,source)
prepareDictionary(source)
const includeRoute=includesRoute(target),current=snapshot(source,includeRoute),receipt=path.join(target,RECEIPT)
let previous=fs.existsSync(receipt)?readJson(receipt):null
const oldMeta=path.join(target,'src/assets/ime-dict/meta.json') const oldMeta=path.join(target,'src/assets/ime-dict/meta.json')
if(fs.existsSync(oldMeta)){ if(!previous&&fs.existsSync(oldMeta)){
const current=JSON.parse(fs.readFileSync(path.join(app,'src/assets/ime-dict/meta.json'),'utf8')).shards const oldDictionary=readJson(oldMeta),oldShards=[...oldDictionary.shards,...(oldDictionary.association_shards||[]),...(oldDictionary.completion_shards||[])]
for(const name of JSON.parse(fs.readFileSync(oldMeta,'utf8')).shards){ if(!Array.isArray(oldShards)||oldShards.some(name=>!/^(short|s?[0-9]+|ds?[0-9a-d]+|[ac][0-9a-f]{2})$/.test(name)))throw Error('Invalid previous dictionary metadata')
if(!/^(short|s?[0-9]+|ds?[0-9a-d]+)$/.test(name))throw Error('Invalid generated shard name') previous={files:Object.fromEntries(oldShards.map(name=>['src/assets/ime-dict/'+name+'.json',{}]))}
const old=path.join(target,'src/assets/ime-dict',name+'.json')
if(!current.includes(name)&&fs.existsSync(old))fs.unlinkSync(old)
} }
let copied=0,removed=0
// An interrupted copy must fail the build gate.
if(fs.existsSync(receipt))fs.unlinkSync(receipt)
for(const [name,info]of Object.entries(current.files)){
const destination=path.join(target,name)
if(fs.existsSync(destination)&&digest(fs.readFileSync(destination))===info.sha256)continue
const bytes=fs.readFileSync(sourceFile(source,name))
if(digest(bytes)!==info.sha256)throw Error('Input source changed during synchronization; retry')
fs.mkdirSync(path.dirname(destination),{recursive:true});fs.writeFileSync(destination,bytes);copied++
} }
for(const relative of ['keyboard/index.ux','common/ime-engine.js','common/ime-lifecycle.js','assets/ime-dict','assets/ime-icons']){ // Only delete recorded, owned files; never touch host assets or business data.
const destination=path.join(target,'src',relative);fs.mkdirSync(path.dirname(destination),{recursive:true});fs.cpSync(path.join(app,'src',relative),destination,{recursive:true}) for(const name of Object.keys(previous?.files||{}))if(!current.files[name]){
if(!managed(name))throw Error('Invalid managed input path')
const destination=path.join(target,name);if(fs.existsSync(destination)){fs.unlinkSync(destination);removed++}
}
const manifestFile=path.join(target,'src/manifest.json'),manifest=readJson(manifestFile)
const before=JSON.stringify(manifest)
if(includeRoute)manifest.router.pages.keyboard={component:'index'}
else delete manifest.router.pages.keyboard
if(JSON.stringify(manifest)!==before)fs.writeFileSync(manifestFile,JSON.stringify(manifest,null,2)+'\n')
if(snapshot(source,includeRoute).sourceHash!==current.sourceHash)throw Error('Input source changed during synchronization; retry after edits settle')
fs.writeFileSync(receipt,JSON.stringify(current,null,2)+'\n')
return {sourceHash:current.sourceHash,files:Object.keys(current.files).length,copied,removed}
}
export function watchInput(target,source=APP,onSync=console.log){
let timer=null,stopped=false,lastHash=''
const synchronize=()=>{
timer=null;if(stopped)return
try{prepareDictionary(source);const hash=snapshot(source,includesRoute(target)).sourceHash;if(hash!==lastHash){const result=syncInput(target,source);lastHash=result.sourceHash;onSync({status:'synchronized',...result})}}
catch(error){lastHash='';onSync({status:'retrying',error:error.message})}
}
const schedule=()=>{if(timer)clearTimeout(timer);timer=setTimeout(synchronize,200)}
const watchers=['src','third_party','LICENSE','NOTICE.md'].map(root=>fs.watch(path.join(source,root),{recursive:true},schedule))
const poll=setInterval(synchronize,2000);synchronize()
return ()=>{stopped=true;if(timer)clearTimeout(timer);clearInterval(poll);for(const watcher of watchers)watcher.close()}
}
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
const [target,...flags]=process.argv.slice(2)
if(!target||flags.some(flag=>!['--check','--watch'].includes(flag))||flags.length>1)throw Error('Usage: node scripts/install.mjs <Glyphix host project> [--check | --watch]')
if(flags.includes('--watch')){
const stop=watchInput(path.resolve(target));process.once('SIGINT',()=>{stop();process.exit(0)});process.once('SIGTERM',()=>{stop();process.exit(0)})
}else if(flags.includes('--check')){const result=checkInput(path.resolve(target));console.log('Input synchronized:',result.sourceHash)}
else console.log('Input synchronized:',syncInput(path.resolve(target)))
} }
const license=path.join(target,'src/assets/crab-keyboard-license.txt')
fs.copyFileSync(path.join(app,'third_party/crabKeyboard/LICENSE'),license)
fs.copyFileSync(path.join(app,'third_party/jieba/LICENSE'),path.join(target,'src/assets/jieba-license.txt'))
const manifestPath=path.join(target,'src/manifest.json'),manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'))
manifest.router.pages.keyboard={component:'index'}
fs.writeFileSync(manifestPath,JSON.stringify(manifest,null,2)+'\n')
console.log('Installed canonical offline keyboard:',manifest.package)
@@ -0,0 +1,32 @@
{
"你好": ["世界", "呀", "啊"],
"谢谢": ["你", "大家", "支持"],
"不客": ["气"],
"早上": ["好"],
"晚上": ["好"],
"晚安": ["啦"],
"再": ["见"],
"生日": ["快乐"],
"新年": ["快乐"],
"今天": ["天气", "下午", "晚上"],
"明天": ["见", "早上", "下午"],
"天气": ["很好", "不错"],
"我": ["想", "要", "是", "们"],
"我们": ["一起", "可以", "去"],
"你": ["好", "的", "们"],
"请": ["问", "稍等", "帮忙"],
"没": ["关系", "问题"],
"没有": ["问题", "关系"],
"可以": ["的", "吗"],
"知道": ["了"],
"收到": ["了"],
"我爱": ["你"],
"人工": ["智能"],
"中国": ["人民", "文化"],
"北京": ["大学", "天气"],
"正在": ["输入", "加载"],
"输入": ["法", "文字"],
"手表": ["应用", "设置"],
"周末": ["快乐", "愉快"],
"工作": ["顺利"]
}
@@ -26,6 +26,25 @@ export function digitsOf(text) {
return result return result
} }
export function chars(text){return Array.from(String(text||''))} export function chars(text){return Array.from(String(text||''))}
export function contextTail(text){const match=String(text||'').match(/[\u3400-\u9fff]{1,4}$/);return match?match[0]:''}
function predictiveBucket(value){let result=0;for(let i=0;i<value.length;i++)result=(result*31+value.charCodeAt(i))&65535;return (result%256+256).toString(16).slice(1)}
// Four columns and four row units. Phrases take more width/height, never ellipsis.
export function candidatePages(candidates) {
const pages=[];let rows=[],usedRows=0,row=null
function flushRow(){if(row){rows.push(row);usedRows+=row.units;row=null}}
function flushPage(){flushRow();if(rows.length)pages.push(rows);rows=[];usedRows=0}
for(const item of candidates){
const length=chars(item.word).length
const span=length===1?1:length<=4?2:4
const units=span===4?Math.max(1,Math.ceil(length/12)):1
if(row&&(row.slots+span>4||row.units!==units))flushRow()
if(usedRows+units>4)flushPage()
if(!row)row={items:[],slots:0,units}
row.items.push({item,span});row.slots+=span
if(row.slots===4)flushRow()
}
flushPage();return pages
}
export function edit(text,cursor,insertion,maxLength) { export function edit(text,cursor,insertion,maxLength) {
const value=chars(text), at=Math.max(0,Math.min(value.length,cursor)) const value=chars(text), at=Math.max(0,Math.min(value.length,cursor))
const room=Math.max(0,maxLength-value.length), added=chars(insertion).slice(0,room) const room=Math.max(0,maxLength-value.length), added=chars(insertion).slice(0,room)
@@ -36,11 +55,21 @@ export function erase(text,cursor) {
if(at)value.splice(at-1,1) if(at)value.splice(at-1,1)
return {text:value.join(''),cursor:Math.max(0,at-1)} return {text:value.join(''),cursor:Math.max(0,at-1)}
} }
export function preview(text,cursor,secret) { export function previewParts(text,cursor,secret) {
const value=chars(text),at=Math.max(0,Math.min(value.length,cursor)) const value=chars(text),at=Math.max(0,Math.min(value.length,cursor))
const left=value.slice(Math.max(0,at-15),at),right=value.slice(at,at+7) const left=value.slice(Math.max(0,at-15),at),right=value.slice(at,at+7)
return (at>15?'…':'')+(secret?'•'.repeat(left.length):left.join(''))+'│'+(secret?'•'.repeat(right.length):right.join(''))+(at+7<value.length?'…':'') const width=list=>list.reduce((sum,ch)=>sum+(secret?.6:/[MW@#%]/.test(ch)?.95:ch.codePointAt(0)>0x2e7f?1:.6),0)
// Keep the cursor in the narrowest round preview, including both ellipses.
// The actual cursor is a separate one-pixel element, never a font glyph.
const size=()=>width(left)+width(right)+(left.length<at?1:0)+(right.length<value.length-at?1:0)
while(size()>7&&(left.length||right.length)){
if(left.length&&(width(left)>4.5||!right.length))left.shift()
else if(right.length)right.pop()
else left.shift()
} }
return {before:(left.length<at?'…':'')+(secret?'•'.repeat(left.length):left.join('')),after:(secret?'•'.repeat(right.length):right.join(''))+(right.length<value.length-at?'…':'')}
}
export function preview(text,cursor,secret) {const parts=previewParts(text,cursor,secret);return parts.before+'│'+parts.after}
export function sanitizePreferences(input) { export function sanitizePreferences(input) {
const result={} const result={}
if(!input||typeof input!=='object'||Array.isArray(input))return result if(!input||typeof input!=='object'||Array.isArray(input))return result
@@ -76,15 +105,28 @@ export function createDictionary(read) {
})() })()
try{return await pending[name]}finally{delete pending[name]} try{return await pending[name]}finally{delete pending[name]}
} }
async function metadata(){if(!manifest){const meta=await read('/assets/ime-dict/meta.json');manifest=typeof meta==='string'?JSON.parse(meta):meta}return manifest}
async function predict(context){
const tail=contextTail(context);if(!tail)return []
const meta=await metadata(),result=[],seen={}
for(let length=tail.length;length>0&&result.length<8;length--){
const prefix=tail.slice(-length),name='a'+predictiveBucket(prefix)
if(!meta.association_shards||meta.association_shards.indexOf(name)<0)continue
const rows=(await load(name))[prefix]||[]
for(const row of rows)if(!seen[row[0]]){seen[row[0]]=true;result.push({word:row[0],pinyin:'',consume:0,frequency:row[1]});if(result.length===8)break}
}
return result
}
return { return {
async lookup(raw,t9,preferences) { predict,
async lookup(raw,t9,preferences,context) {
const input=String(raw||'').toLowerCase() const input=String(raw||'').toLowerCase()
const dual=t9==='14',ambiguous=!!t9 const dual=t9==='14',ambiguous=!!t9
if(!(dual?/^[0-9a-d]{1,32}$/:t9?/^[2-9]{1,32}$/:/^[a-z]{1,32}$/).test(input))return [] if(!(dual?/^[0-9a-d]{1,32}$/:t9?/^[2-9]{1,32}$/:/^[a-z]{1,32}$/).test(input))return []
const digits=ambiguous?input:digitsOf(input) const digits=ambiguous?input:digitsOf(input)
const merged={},frequencies={} const merged={},frequencies={}
function merge(data){Object.assign(merged,data.entries);Object.assign(frequencies,data.frequencies)} function merge(data){Object.assign(merged,data.entries);Object.assign(frequencies,data.frequencies)}
if(!manifest){const meta=await read('/assets/ime-dict/meta.json');manifest=typeof meta==='string'?JSON.parse(meta):meta} await metadata()
const names=[] const names=[]
const prefixLength=dual?3:4 const prefixLength=dual?3:4
for(let length=1;length<=Math.min(prefixLength-1,digits.length);length++)names.push((dual?'ds':'s')+digits.slice(0,length)) for(let length=1;length<=Math.min(prefixLength-1,digits.length);length++)names.push((dual?'ds':'s')+digits.slice(0,length))
@@ -95,13 +137,32 @@ export function createDictionary(read) {
const result=[],seen={} const result=[],seen={}
for(const key of matching)for(const word of merged[key]){ for(const key of matching)for(const word of merged[key]){
const identity=word+'@'+key.length const identity=word+'@'+key.length
if(!seen[identity]){seen[identity]=true;result.push({word,pinyin:key,consume:key.length})} if(!seen[identity]){seen[identity]=true;result.push({word,pinyin:key,consume:key.length,frequency:frequencies[word]||0})}
}
if(input.length>=2){
const mode=dual?'14':t9?'t9':'py',prefix=mode+':'+input,extras=[]
if(input.length<prefixLength){
const name='c'+predictiveBucket(prefix)
if(manifest.completion_shards&&manifest.completion_shards.indexOf(name)>=0){
for(const row of (await load(name))[prefix]||[])extras.push({word:row[0],pinyin:row[1],frequency:row[2],missing:row[1].length-input.length,consume:input.length,completion:true})
}
}else for(const key of Object.keys(merged)){
const code=dual?code14Of(key):t9?digitsOf(key):key
if(code.length<=input.length||code.length>input.length+4||code.indexOf(input)!==0)continue
for(const word of merged[key])extras.push({word,pinyin:key,frequency:frequencies[word]||0,missing:key.length-input.length,consume:input.length,completion:true})
}
extras.sort((a,b)=>b.frequency-a.frequency||a.missing-b.missing)
let added=0
for(const item of extras){const identity=item.word+'@'+item.consume;if(!seen[identity]){seen[identity]=true;result.push(item);if(++added===12)break}}
} }
const preferenceKey=dual?'k14:'+input:input const preferenceKey=dual?'k14:'+input:input
const prefs=preferences&&Array.isArray(preferences[preferenceKey])?preferences[preferenceKey]:[] const prefs=preferences&&Array.isArray(preferences[preferenceKey])?preferences[preferenceKey]:[]
const rank=item=>{const i=prefs.findIndex(x=>x.word===item.word&&x.consume===item.consume);return i<0?100:i} const rank=item=>{const i=prefs.findIndex(x=>x.word===item.word&&x.consume===item.consume);return i<0?100:i}
// Rank only real dictionary results: a damaged preference cannot inject text. const predictions=await predict(context),contextRank=item=>{const i=predictions.findIndex(x=>x.word===item.word);return i<0?0:8-i}
return result.map((item,index)=>({item,index})).sort((a,b)=>rank(a.item)-rank(b.item)||(ambiguous?(b.item.consume-a.item.consume||(frequencies[b.item.word]||0)-(frequencies[a.item.word]||0)):0)||a.index-b.index).map(x=>x.item) const category=item=>item.completion?1:item.consume===input.length?0:2
// Preserve every exact candidate; complete unfinished syllables before
// shorter fallbacks, and use word frequency plus the editor's local context.
return result.map((item,index)=>({item,index})).sort((a,b)=>rank(a.item)-rank(b.item)||category(a.item)-category(b.item)||b.item.consume-a.item.consume||contextRank(b.item)-contextRank(a.item)||(b.item.frequency||0)-(a.item.frequency||0)||a.index-b.index).map(x=>x.item)
}, },
clear(){cache.length=0;manifest=null}, clear(){cache.length=0;manifest=null},
get cacheSize(){return cache.length} get cacheSize(){return cache.length}
@@ -1,5 +1,10 @@
// Some vendor runtimes deliver application hide before the nested page callback. // Component instances are not router pages. Keep references outside reactive data.
const inputs=[]
export function registerInput(input){inputs.push(input)}
export function unregisterInput(input){const index=inputs.indexOf(input);if(index>=0)inputs.splice(index,1)}
export function activeInputs(){return inputs.slice()}
export function clearPrivateInputs(router) { export function clearPrivateInputs(router) {
for(const input of activeInputs())input.onHide()
for(const page of router.getPages()) { for(const page of router.getPages()) {
if(page.secret&&typeof page.clearPrivate==='function')page.clearPrivate() if(page.secret&&typeof page.clearPrivate==='function')page.clearPrivate()
} }
@@ -0,0 +1,25 @@
// Shared app/keyboard colors. Dark colors match Android Wrist Star Chat.
export function normalizeTheme(value) { return value === 'blackgreen' ? 'blackgreen' : 'bluewhite' }
export function themeStyles(value) {
const dark = normalizeTheme(value) === 'blackgreen'
const c = dark
? {bg:'#000000',card:'#1C1C1E',raised:'#2C2C2E',text:'#F2F7FA',muted:'#B3B3B8',dim:'#707075',accent:'#00C777',user:'#0E5C44',line:'#3C3C40',danger:'#FF6978'}
: {bg:'#ECEEF2',card:'#FFFFFF',raised:'#F8F9FB',text:'#20232B',muted:'#737984',dim:'#737984',accent:'#2867D9',user:'#2867D9',line:'#D9DDE4',danger:'#B5424C'}
const ink='color:'+c.text+';',muted='color:'+c.muted+';'
return {
surface:'background-color:'+c.bg+';'+ink,
card:'background-color:'+c.card+';'+ink,
caret:'background-color:'+c.text+';',
raised:'background-color:'+c.raised+';'+ink,
cyan:'color:'+(dark?'#4DB5FF':'#2867D9')+';',
ink,muted,dim:'color:'+c.dim+';',accent:'color:'+c.accent+';',
primary:'background-color:'+c.accent+';color:'+(dark?'#000000':'#FFFFFF')+';',
secondary:'background-color:'+c.raised+';'+ink,
user:'background-color:'+c.user+';color:#FFFFFF;',white:'color:#FFFFFF;',
input:'background-color:'+c.card+';'+ink+'border-bottom:2px solid '+c.line+';',
plain:'background-color:transparent;'+ink,
icon:'background-color:'+(dark?c.accent:'transparent')+';',
danger:'background-color:'+c.card+';color:'+c.danger+';',
selection:'background-color:'+c.raised+';border-bottom:2px solid '+c.accent+';'+ink
}
}
@@ -0,0 +1,162 @@
<template>
<div class="ime-surface" :style="themeStyles.surface">
<div class="ime" :style="themeStyles.surface">
<div class="top"><button id="ime-cancel" class="cancel icon-button" :style="themeStyles.icon" on:click="cancel"><image class="icon" src="/assets/ime-icons/close-line.png" /></button><p class="title" :style="themeStyles.muted">{{titleLabel}}</p><p class="count" :style="themeStyles.dim">{{count}} 字</p></div>
<button id="ime-preview" class="preview" :style="themeStyles.card" on:click="showTools"><p if="count" id="ime-preview-before" class="preview-text" :style="themeStyles.ink">{{previewBefore}}</p><div if="count" id="ime-caret" class="preview-caret" :style="themeStyles.caret"></div><p if="count" id="ime-preview-after" class="preview-text" :style="themeStyles.ink">{{previewAfter}}</p><p if="!count" class="preview-text" :style="themeStyles.ink">输入文字…</p></button>
<div if="panel !== 'candidates'" id="ime-composition" class="composition"><p class="spelling" :style="themeStyles.accent">{{compositionDisplay || modeLabel}}</p><p class="hint" :style="themeStyles.dim">{{hint}}</p></div>
<div if="panel === 'keys'" class="body">
<div if="mode === 't9' || mode === 'py' || mode === '14' || mode === 'sym' || inputType === 'url'" id="ime-candidate-strip" class="candidates" :style="themeStyles.raised"><button for="item in bar" class="candidate" :style="themeStyles.plain" on:click="choose(item)">{{item.word}}</button><button if="!composition &amp;&amp; inputType === 'url'" class="candidate" :style="themeStyles.plain" on:click="insert('https://')">https://</button><button if="!composition &amp;&amp; inputType === 'url'" class="candidate" :style="themeStyles.plain" on:click="insert('.com')">.com</button><button if="mode === 'sym'" class="candidate" :style="themeStyles.plain" on:click="symbols">下一组符号 {{symbolPage+1}}/2</button><p if="!total &amp;&amp; !busy &amp;&amp; mode !== 'sym' &amp;&amp; inputType !== 'url'" class="candidate-placeholder" :style="themeStyles.dim">{{secret ? '私密输入' : '选择候选词'}}</p><button if="mode !== 'sym' &amp;&amp; total" id="ime-expand" class="more icon-button" :style="themeStyles.icon" on:click="expand"><image class="icon" src="/assets/ime-icons/arrow-down-s-line.png" /></button></div>
<div if="mode === 'py' || mode === 'en'" id="ime-keypad" class="keypad" :style="keypadStyle">
<div class="keyrow qwerty-first" :style="layoutRows[0].style"><button for="key in keyRows[0]" :id="'ime-key-' + key.value.toLowerCase()" class="key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
<div class="keyrow qwerty-middle"><button for="key in keyRows[1]" :id="'ime-key-' + key.value.toLowerCase()" class="key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
<div class="keyrow qwerty-last" :style="layoutRows[2].style"><button for="key in keyRows[2]" :id="'ime-key-' + key.value.toLowerCase()" class="key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
</div>
<div if="mode !== 'py' &amp;&amp; mode !== 'en'" id="ime-keypad" class="keypad">
<div for="row in layoutRows" class="keyrow" :style="row.style"><button for="key in row.keys" class="key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p><p if="key.sub" class="key-sub" :style="themeStyles.dim">{{key.sub}}</p></button></div>
</div>
<div class="modes"><button id="ime-chinese" :style="themeStyles.plain" on:click="chinese">{{mode === 't9' ? '26键' : '九宫'}}</button><button id="ime-14" :style="mode === '14' ? themeStyles.accent : themeStyles.muted" on:click="fourteen">14键</button><button id="ime-english" :style="themeStyles.card" on:click="english">{{mode === 'en' ? (upper ? '小写' : '大写') : 'abc'}}</button><button :style="themeStyles.card" on:click="numbers">123</button><button :style="themeStyles.card" on:click="symbols">符号</button><button id="ime-tools" class="icon-button" :style="themeStyles.icon" on:click="showTools"><image class="icon small-icon" src="/assets/ime-icons/edit-2-line.png" /></button></div>
<div class="bottom"><button id="ime-space" :style="themeStyles.card" on:click="space">空格</button><button id="ime-delete" class="delete icon-button" :style="themeStyles.icon" on:touchstart="startDelete" on:touchend="endDelete"><image class="icon" src="/assets/ime-icons/delete-back-2-line.png" /></button><button id="ime-finish" class="done icon-button" :style="themeStyles.primary" on:click="finish"><image class="icon" src="/assets/ime-icons/check-line.png" /></button></div>
</div>
<div if="panel === 'candidates'" class="body expanded">
<p class="page-info" :style="themeStyles.dim">完整候选 · {{total}} 项 · {{candidatePage + 1}}/{{pageCount}}</p>
<div class="full-list"><div for="row in candidateRows" class="candidate-grid-row" :style="row.style"><button for="entry in row.items" class="full-candidate" :style="entry.style" on:click="choose(entry.item)"><p class="word" :style="entry.labelStyle">{{entry.item.word}}</p></button></div><p if="total === 0" class="empty" :style="themeStyles.dim">{{busy ? '正在查词…' : '继续输入后查看候选'}}</p></div>
<div class="pager"><button id="ime-page-prev" class="icon-button" :style="themeStyles.icon" on:click="previousPage"><image class="icon" src="/assets/ime-icons/arrow-left-s-line.png" /></button><button id="ime-page-next" class="icon-button" :style="themeStyles.icon" on:click="nextPage"><image class="icon" src="/assets/ime-icons/arrow-right-s-line.png" /></button></div>
<div class="bottom"><button id="ime-back" class="icon-button" :style="themeStyles.icon" on:click="closePanel"><image class="icon" src="/assets/ime-icons/keyboard-box-line.png" /></button><button id="ime-delete" class="delete icon-button" :style="themeStyles.icon" on:click="backspace"><image class="icon" src="/assets/ime-icons/delete-back-2-line.png" /></button><button id="ime-finish" class="done icon-button" :style="themeStyles.primary" on:click="finish"><image class="icon" src="/assets/ime-icons/check-line.png" /></button></div>
</div>
<div if="panel === 'tools'" class="body tools">
<p class="page-info" :style="themeStyles.dim">{{secret ? '私密输入 · 不学习、不保存' : '光标编辑 · 输入仅保留在本机'}}</p>
<div class="toolrow"><button :style="themeStyles.card" on:click="cursorLeft">← 左移</button><button :style="themeStyles.card" on:click="cursorRight">右移 →</button></div>
<div class="toolrow"><button :style="themeStyles.card" on:click="cursorStart">到开头</button><button :style="themeStyles.card" on:click="cursorEnd">到末尾</button></div>
<div class="toolrow"><button :style="themeStyles.card" on:click="newline">换行</button><button :style="themeStyles.card" on:click="rawInput">拼音上屏</button></div>
<div class="toolrow"><button :style="themeStyles.card" on:click="toggleLearning">{{learning ? '学习:开' : '学习:关'}}</button><button :style="themeStyles.card" on:click="clearPreferences">清除学习</button></div>
<div class="toolrow"><button class="danger" :style="themeStyles.danger" on:click="clearText">{{clearArmed ? '确认清空' : '清空全文'}}</button><button :style="themeStyles.card" on:click="backspace">删除一字</button></div>
<div class="bottom"><button id="ime-back" class="icon-button" :style="themeStyles.icon" on:click="closePanel"><image class="icon" src="/assets/ime-icons/keyboard-box-line.png" /></button><button id="ime-finish" class="done icon-button" :style="themeStyles.primary" on:click="finish"><image class="icon" src="/assets/ime-icons/check-line.png" /></button></div>
</div>
</div>
</div>
</template>
<style>
.ime-surface{display:flex;flex-direction:column;width:100%;height:100%;background-color:#ECEEF2}
.ime{display:flex;flex-direction:column;align-items:stretch;width:100%;height:100%;padding:14px 10px;background-color:#ECEEF2;color:#20232B}
p{margin:0;color:#20232B;font-size:.62rem}button{padding:0;margin:3px;border-radius:12px;color:#20232B;background-color:#FFFFFF;font-size:.65rem;flex-shrink:0}button:active{background-color:#D7E5FF}
.icon-button{display:flex;align-items:center;justify-content:center}.icon{width:24px;height:24px;object-fit:contain}.small-icon{width:20px;height:20px}
.top,.composition,.candidates,.keyrow,.modes,.bottom,.pager,.toolrow{display:flex;flex-direction:row;align-items:center;justify-content:center;flex-shrink:0}
.top{height:27px}.cancel{width:44px;height:27px;background-color:transparent;color:#2867D9;font-size:.53rem}.title{flex:1;text-align:center;font-size:.5rem;color:#737984}.count{width:44px;font-size:.4rem;text-align:right;color:#9297A1}
.preview{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;height:43px;margin:7px 7px 3px;padding:0 14px;background-color:#FFFFFF;text-align:left;font-size:.82rem;color:#20232B;border-radius:13px}.preview-text{margin:0;padding:0;font-size:.82rem;flex-shrink:0}.preview-caret{width:1px;height:24px;margin:0;padding:0;flex-shrink:0}
.composition{height:24px;padding:0 12px}.spelling{flex:1;color:#2867D9;font-size:.55rem}.hint{color:#8B909A;font-size:.38rem;text-align:right}
.body,.keypad,.full-list{display:flex;flex-direction:column;align-items:stretch;min-height:0}.body{flex:1;justify-content:flex-start}.keypad{flex-shrink:0}
.candidates{height:42px;margin:0 4px 4px;background-color:#F8F9FB;border-radius:12px}.candidate{flex:1;min-width:45px;height:36px;background-color:transparent;color:#20232B;font-size:.78rem}.candidate-placeholder{flex:1;text-align:center;color:#A4A9B2;font-size:.45rem}.more{width:46px;height:36px;background-color:transparent;font-size:.5rem;color:#2867D9}
.keyrow{align-items:stretch;align-self:center;width:100%}.key{display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;height:52px;min-width:0;background-color:#FFFFFF;border-radius:12px}.key-label{font-size:.88rem;color:#22252E}.key-sub{font-size:.35rem;color:#9DA2AD;margin-top:1px}
.qwerty-first,.qwerty-first-py{width:100%}.qwerty-middle{width:94%}.qwerty-last,.qwerty-last-py{width:84%}
.modes{height:36px;margin-top:3px}.modes button{flex:1;height:31px;background-color:transparent;font-size:.53rem;color:#636A77}
.bottom{height:41px;margin-top:2px}.bottom button{flex:1;height:37px;font-size:.6rem;background-color:#FFFFFF}.bottom .done{background-color:#2867D9;color:#FFFFFF}.bottom .delete{background-color:#D9DDE4;color:#515866}
.page-info{text-align:center;color:#8B919D;font-size:.45rem;margin:5px 0;flex-shrink:0}.full-list{flex:1;justify-content:flex-start}.candidate-grid-row{display:flex;flex-direction:row;align-self:flex-start;align-items:stretch;flex-shrink:0}.full-candidate{display:flex;align-items:center;justify-content:center;flex:1;min-width:0;margin:2px;padding:2px 4px;background-color:#FFFFFF;border-radius:10px}.word{width:100%;text-align:center;font-size:25px;color:#22252E}.empty{margin:15px 8px;color:#9299A5;text-align:center}
.pager{height:38px}.pager button{flex:1;height:34px;background-color:transparent;color:#2867D9;font-size:.55rem}.toolrow button{flex:1;height:40px;font-size:.57rem}.tools{justify-content:center}.tools .danger{color:#C85656}.tools .bottom{margin-top:8px}
@media (shape:circle){.ime{padding:30px 8px}.top{align-self:center;width:186px;height:24px}.cancel{height:24px}.preview{align-self:center;width:280px;height:32px;margin-top:5px}.composition{align-self:center;width:300px;height:23px}.candidates{align-self:center;width:352px;height:38px}.candidate{height:34px}.more{height:34px}.keypad{align-self:center;width:368px}.key{height:49px}.modes{align-self:center;width:286px;height:33px}.modes button{height:29px}.bottom{align-self:center;width:200px;height:38px}.bottom button{height:34px}.full-list{align-self:center;width:340px}.pager{align-self:center;width:276px}.toolrow{align-self:center;width:300px}.toolrow button{height:34px}.tools .page-info{align-self:center;width:280px}.tools .bottom{margin-top:4px}}
@media (shape:circle){.qwerty-first{width:92%}.qwerty-first-py{width:96%}.qwerty-middle{width:100%}.qwerty-last{width:90%}.qwerty-last-py{width:80%}}
</style>
<script>
import file from '@system.file'
import storage from '@system.storage'
import {registerInput,unregisterInput} from '../../common/ime-lifecycle.js'
import {normalizeTheme, themeStyles as stylesForTheme} from '../../common/ime-theme.js'
import {createDictionary,chars,contextTail,candidatePages,edit,erase,previewParts,learn,sanitizePreferences,KEY14_GROUPS,display14} from '../../common/ime-engine.js'
const LETTERS=['qwertyuiop','asdfghjkl','zxcvbnm']
const SYMBOLS=['!"#$%&',"'()*+,",'-./:;<','=>?@[\\',']^_`{|','}~,。?!']
function keys(rows){return rows.map(row=>Array.from(row).map(value=>({label:value,value:value,sub:''})))}
export default {
data:{config:{},titleLabel:'蟹蟹输入',inputTheme:'bluewhite',themeStyles:stylesForTheme('bluewhite'),initial:'',title:'蟹蟹输入',inputType:'text',maxLength:2048,allowNewline:true,secret:false,revealSecret:false,text:'',cursor:0,composition:'',compositionDisplay:'',display:'',previewBefore:'',previewAfter:'',count:0,limit:2048,mode:'t9',modeLabel:'九宫拼音',upper:false,symbolPage:0,panel:'keys',keyRows:[],layoutRows:[],keyStyle:'',keypadStyle:'',labelStyle:'',bar:[],visibleCandidates:[],candidateRows:[],total:0,candidatePage:0,pageCount:1,busy:false,hint:'离线全量词库',learning:false,clearArmed:false},
dictionary:null,preferences:{},allCandidates:[],pages:[],prediction:false,active:true,life:null,requestId:0,lookupTimer:null,repeatTimer:null,repeatDelay:null,ended:false,original:'',
onInit(){
for(const key of ['initial','title','inputType','maxLength','allowNewline','inputTheme','secret','revealSecret'])if(this.config[key]!==undefined)this[key]=this.config[key]
registerInput(this)
this.titleLabel=chars(this.title).slice(0,4).join('')+(chars(this.title).length>4?'…':'');this.inputTheme=normalizeTheme(this.inputTheme);this.themeStyles=stylesForTheme(this.inputTheme)
this.life={active:true,id:0};this.secret=!!this.secret||this.inputType==='password'
this.limit=Math.floor(Math.max(1,Math.min(4096,Number(this.maxLength)||2048)))
this.text=chars(this.initial).slice(0,this.limit).join('');this.original=this.text;this.cursor=chars(this.text).length
try{this.preferences=sanitizePreferences(storage.get('crab.ime.preferences'));this.learning=!this.secret&&storage.get('crab.ime.learning')===true}catch(error){this.preferences={};this.learning=false}
this.dictionary=createDictionary(uri=>file.readText({uri}))
this.mode=this.inputType==='number'||this.inputType==='pin'?'num':this.inputType==='url'||this.inputType==='password'?'en':'t9'
this.renderKeys();this.updateDisplay()
},
onShow(){this.active=true;this.life.active=true;this.schedule()},
onHide(){this.active=false;this.life.active=false;this.invalidate();this.stopRepeat();if(this.secret)this.clearPrivate()},
clearPrivate(){this.active=false;this.life.active=false;this.invalidate();this.stopRepeat();this.text='';this.initial='';this.original='';this.composition='';this.allCandidates=[];this.bar=[];this.visibleCandidates=[];this.candidateRows=[];this.pages=[];this.total=0;this.config={};this.updateDisplay()},
onDestroy(){unregisterInput(this);this.active=false;if(this.life)this.life.active=false;this.invalidate();this.stopRepeat();if(this.dictionary)this.dictionary.clear();this.allCandidates=[];this.preferences={};this.text='';this.initial='';this.original='';this.config={}},
invalidate(){this.requestId++;this.life.id=this.requestId;if(this.lookupTimer!==null){clearTimeout(this.lookupTimer);this.lookupTimer=null}this.busy=false},
updateDisplay(){const count=chars(this.text).length,parts=previewParts(this.text,this.cursor,this.secret&&!this.revealSecret),display=parts.before+parts.after,composition=this.mode==='14'?display14(this.composition):this.composition;if(this.count!==count)this.count=count;if(this.display!==display)this.display=display;if(this.previewBefore!==parts.before)this.previewBefore=parts.before;if(this.previewAfter!==parts.after)this.previewAfter=parts.after;if(this.compositionDisplay!==composition)this.compositionDisplay=composition},
inputContext(){return this.secret?'':contextTail(chars(this.text).slice(0,this.cursor).join(''))},
clearCandidates(){this.allCandidates=[];this.bar=[];this.visibleCandidates=[];this.candidateRows=[];this.pages=[];this.total=0;this.pageCount=1},
renderKeys(){
if(this.mode==='t9')this.keyRows=[[{label:'标点',value:'symbols',sub:'1'},{label:'ABC',value:'2',sub:'2'},{label:'DEF',value:'3',sub:'3'}],[{label:'GHI',value:'4',sub:'4'},{label:'JKL',value:'5',sub:'5'},{label:'MNO',value:'6',sub:'6'}],[{label:'PQRS',value:'7',sub:'7'},{label:'TUV',value:'8',sub:'8'},{label:'WXYZ',value:'9',sub:'9'}]]
else if(this.mode==='num')this.keyRows=keys(['123','456','789','.0-'])
else if(this.mode==='sym')this.keyRows=keys(SYMBOLS.slice(this.symbolPage*3,this.symbolPage*3+3))
else if(this.mode==='14'){const all=KEY14_GROUPS.map((group,index)=>({label:group.toUpperCase(),value:'0123456789abcd'[index],sub:''}));this.keyRows=[all.slice(0,5),all.slice(5,10),all.slice(10)]}
else this.keyRows=keys(LETTERS.map(row=>this.upper&&this.mode==='en'?row.toUpperCase():row))
const qwerty=this.mode==='py'||this.mode==='en'
this.keyStyle='height:'+(qwerty?(this.mode==='en'?'58':'49'):this.keyRows.length<=3?'49':this.keyRows.length===4?'38':this.keyRows.length===5?'28':'22')+'px;'+this.themeStyles.card
this.keypadStyle=qwerty?'width:100%;':''
this.labelStyle=(qwerty?'font-size:22px;':this.mode==='14'?'font-size:24px;':'')+this.themeStyles.ink
this.layoutRows=this.keyRows.map((row,index)=>({keys:row,style:qwerty?(this.mode==='py'&&index!==1?'width:'+(index===0?'96':'80')+'%;':''):'width:'+(index===this.keyRows.length-1?'91':'100')+'%'}))
this.modeLabel={t9:'九宫拼音',py:'26键全拼 · v 代表 ü','14':'14键拼音',en:this.upper?'英文大写':'英文小写',num:'数字',sym:'符号 · '+(this.symbolPage+1)+'/2'}[this.mode]
this.compositionDisplay=this.mode==='14'?display14(this.composition):this.composition
},
setMode(mode){this.stopRepeat();if(this.composition&&mode!==this.mode){if(this.mode==='t9'||mode==='t9'||this.mode==='14'||mode==='14'){this.hint='请先选择候选词再切换';return}if(mode!=='py'&&!this.rawInput())return}this.mode=mode;this.panel='keys';this.renderKeys();this.schedule()},
chinese(){this.setMode(this.mode==='t9'?'py':'t9')},fourteen(){this.setMode('14')},english(){if(this.mode==='en')this.upper=!this.upper;this.setMode('en')},numbers(){this.setMode('num')},symbols(){if(this.mode==='sym'){this.symbolPage=1-this.symbolPage;this.renderKeys()}else{this.symbolPage=0;this.setMode('sym')}},
press(value){this.clearArmed=false;if(value==='symbols'){this.symbols();return}if(this.mode==='t9'||this.mode==='py'||this.mode==='14'){if(this.composition.length>=32){this.hint='每段最多 32 位';return}this.composition+=value.toLowerCase();this.schedule()}else this.insert(this.mode==='en'?(this.upper?String(value).toUpperCase():String(value).toLowerCase()):value)},
insert(value){
let insertion=String(value)
if(this.inputType==='number')insertion=insertion.replace(/[^0-9.\-]/g,'')
if(this.inputType==='pin')insertion=insertion.replace(/[^0-9]/g,'')
if(this.inputType==='url')insertion=insertion.replace(/\s/g,'')
if(!this.allowNewline)insertion=insertion.replace(/[\r\n]/g,'')
const next=edit(this.text,this.cursor,insertion,this.limit);this.text=next.text;this.cursor=next.cursor;this.updateDisplay();this.hint=next.limited?'已达字数上限':'离线输入'
return !next.limited
},
schedule(){
this.compositionDisplay=this.mode==='14'?display14(this.composition):this.composition
this.invalidate();this.candidatePage=0
const chinese=this.mode==='t9'||this.mode==='py'||this.mode==='14',prediction=!this.composition
if(!this.active||!chinese||(!this.composition&&!this.inputContext())){this.clearCandidates();this.prediction=false;return}
if(prediction!==this.prediction)this.clearCandidates()
this.prediction=prediction
const id=this.requestId;this.busy=true;if(!this.bar.length)this.hint=prediction?'联想…':'正在查词…'
this.lookupTimer=setTimeout(()=>{this.lookupTimer=null;this.query(id)},45)
},
async query(id){
const raw=this.composition,life=this.life,context=this.inputContext()
try{const result=raw?await this.dictionary.lookup(raw,this.mode==='14'?'14':this.mode==='t9',this.learning?this.preferences:{},context):await this.dictionary.predict(context)
if(!life.active||id!==life.id)return
this.prediction=!raw;this.allCandidates=result;this.total=result.length;this.bar=result.slice(0,3);this.pages=candidatePages(result);this.pageCount=Math.max(1,this.pages.length);this.candidatePage=0;this.pageCandidates();this.hint=result.length?(!raw?'联想 · ':'')+result.length+' 个候选':raw?'暂无匹配 · 可退格修改':'离线输入'
}catch(error){if(life.active&&id===life.id){this.clearCandidates();this.hint='词库暂不可用,可切换英文输入'}}
finally{if(life.active&&id===life.id)this.busy=false}
},
pageCandidates(){const rows=this.pages[this.candidatePage]||[];this.visibleCandidates=[];this.candidateRows=rows.map(row=>({style:'height:'+(row.units*43)+'px;width:'+(row.slots*25)+'%',items:row.items.map(entry=>{this.visibleCandidates.push(entry.item);return {item:entry.item,style:'flex:'+entry.span+';height:'+(row.units*43-4)+'px;'+this.themeStyles.card,labelStyle:'font-size:'+(entry.span===1?25:entry.span===2?23:20)+'px;'+this.themeStyles.ink}})}))},
choose(item){
if(this.busy||(!this.composition&&(!this.prediction||item.consume!==0))||!this.allCandidates.some(x=>x.word===item.word&&x.pinyin===item.pinyin&&x.consume===item.consume))return
if(chars(this.text).length+chars(item.word).length>this.limit){this.hint='剩余空间不足,未截断候选词';return}
const raw=this.composition;this.insert(item.word)
if(raw&&this.learning&&!this.secret){try{this.preferences=learn(this.preferences,this.mode==='14'?'k14:'+raw:raw,item);storage.set('crab.ime.preferences',this.preferences)}catch(error){this.learning=false}}
this.composition=raw.slice(item.consume);this.panel='keys';this.schedule()
},
expand(){this.panel='candidates';this.pageCandidates()},previousPage(){this.candidatePage=Math.max(0,this.candidatePage-1);this.pageCandidates()},nextPage(){this.candidatePage=Math.min(this.pageCount-1,this.candidatePage+1);this.pageCandidates()},
backspace(){this.clearArmed=false;if(this.composition){this.composition=this.composition.slice(0,-1);this.schedule()}else{const next=erase(this.text,this.cursor);this.text=next.text;this.cursor=next.cursor;this.updateDisplay();this.schedule()}},
startDelete(){this.stopRepeat();this.backspace();this.repeatDelay=setTimeout(()=>{this.repeatDelay=null;this.repeatTimer=setInterval(()=>this.backspace(),120)},450)},
endDelete(){this.stopRepeat()},
stopRepeat(){if(this.repeatTimer!==null){clearInterval(this.repeatTimer);this.repeatTimer=null}if(this.repeatDelay!==null){clearTimeout(this.repeatDelay);this.repeatDelay=null}},
space(){if(this.composition){if(!this.busy&&this.allCandidates.length)this.choose(this.allCandidates[0]);else this.hint='请等待候选或修改拼音'}else{this.insert(' ');this.schedule()}},
rawInput(){if((this.mode==='t9'||this.mode==='14')&&this.composition){this.hint='编码请先选择候选';return false}if(this.composition){if(!this.insert(this.composition))return false;this.composition='';this.schedule()}return true},
showTools(){this.stopRepeat();this.panel='tools';this.clearArmed=false},closePanel(){this.panel='keys';this.clearArmed=false},
cursorLeft(){this.cursor=Math.max(0,this.cursor-1);this.updateDisplay();this.schedule()},cursorRight(){this.cursor=Math.min(chars(this.text).length,this.cursor+1);this.updateDisplay();this.schedule()},cursorStart(){this.cursor=0;this.updateDisplay();this.schedule()},cursorEnd(){this.cursor=chars(this.text).length;this.updateDisplay();this.schedule()},
newline(){if(this.allowNewline&&this.inputType!=='url'&&this.inputType!=='number'&&!this.secret){this.insert('\n');this.schedule()}else this.hint='此输入项不支持换行'},
clearText(){if(!this.clearArmed){this.clearArmed=true;this.hint='再次点击确认清空';return}this.text='';this.cursor=0;this.composition='';this.clearArmed=false;this.schedule();this.updateDisplay()},
toggleLearning(){if(this.secret){this.hint='私密输入不记录学习';return}try{storage.set('crab.ime.learning',!this.learning);this.learning=!this.learning;this.schedule()}catch(error){this.learning=false;this.hint='本机存储不可用,学习已关闭'}},
clearPreferences(){try{storage.delete('crab.ime.preferences');this.preferences={};this.schedule();this.hint='本应用学习记录已清除'}catch(error){this.hint='学习清除失败,请稍后重试'}},
finish(){if(this.ended||!this.active)return;this.stopRepeat();if(this.composition){this.hint='请先选择候选词或在编辑中上屏拼音';this.panel='keys';return}const result={accepted:true,text:this.text,session:this.config.session};this.ended=true;this.clearPrivate();this.$emit('complete',result)},
cancel(){if(this.ended)return;const result={accepted:false,session:this.config.session};this.ended=true;this.clearPrivate();this.$emit('cancel',result)}
}
</script>
+13 -140
查看文件
@@ -1,145 +1,18 @@
<template> <import name="CrabInput" src="../components/CrabInput/index" />
<div class="ime-surface"> <template><div class="keyboard-page"><CrabInput :config="options" on:complete="complete($event)" on:cancel="cancel" /></div></template>
<div class="ime"> <style>.keyboard-page{display:flex;flex-direction:column;width:100%;height:100%}</style>
<div class="top"><button id="ime-cancel" class="cancel icon-button" on:click="cancel"><image class="icon" src="/assets/ime-icons/close-line.png" /></button><p class="title">{{title}}</p><p class="count">{{count}} 字</p></div>
<button class="preview" on:click="showTools">{{count ? display : '输入文字…'}}</button>
<div class="composition"><p class="spelling">{{compositionDisplay || modeLabel}}</p><p class="hint">{{hint}}</p></div>
<div if="panel === 'keys'" class="body">
<div if="mode === 't9' || mode === 'py' || mode === '14' || mode === 'sym' || inputType === 'url'" class="candidates"><button for="item in bar" class="candidate" on:click="choose(item)">{{item.word}}</button><button if="!composition &amp;&amp; inputType === 'url'" class="candidate" on:click="insert('https://')">https://</button><button if="!composition &amp;&amp; inputType === 'url'" class="candidate" on:click="insert('.com')">.com</button><button if="mode === 'sym'" class="candidate" on:click="symbols">下一组符号 {{symbolPage+1}}/2</button><p if="!total &amp;&amp; !busy &amp;&amp; mode !== 'sym' &amp;&amp; inputType !== 'url'" class="candidate-placeholder">{{secret ? '私密输入' : '选择候选词'}}</p><button if="mode !== 'sym' &amp;&amp; total" id="ime-expand" class="more icon-button" on:click="expand"><image class="icon" src="/assets/ime-icons/arrow-down-s-line.png" /></button></div>
<div class="keypad">
<div for="row in layoutRows" class="keyrow" :style="row.style"><button for="key in row.keys" class="key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p><p if="key.sub" class="key-sub">{{key.sub}}</p></button></div>
</div>
<div class="modes"><button id="ime-chinese" on:click="chinese">{{mode === 't9' ? '26键' : '九宫'}}</button><button id="ime-14" :style="mode === '14' ? 'color:#2867D9' : ''" on:click="fourteen">14键</button><button on:click="english">{{upper ? 'ABC' : 'abc'}}</button><button on:click="numbers">123</button><button on:click="symbols">符号</button><button id="ime-tools" class="icon-button" on:click="showTools"><image class="icon small-icon" src="/assets/ime-icons/edit-2-line.png" /></button></div>
<div class="bottom"><button id="ime-space" on:click="space">空格</button><button id="ime-delete" class="delete icon-button" on:touchstart="startDelete" on:touchend="endDelete"><image class="icon" src="/assets/ime-icons/delete-back-2-line.png" /></button><button id="ime-finish" class="done icon-button" on:click="finish"><image class="icon" src="/assets/ime-icons/check-line.png" /></button></div>
</div>
<div if="panel === 'candidates'" class="body expanded">
<p class="page-info">完整候选 · {{total}} 项 · {{candidatePage + 1}}/{{pageCount}}</p>
<div class="full-list"><button for="item in visibleCandidates" class="full-candidate" on:click="choose(item)"><p class="word">{{item.word}}</p><p class="reading">{{item.pinyin}}</p></button><p if="total === 0" class="empty">{{busy ? '正在查词…' : '继续输入拼音后查看候选'}}</p></div>
<div class="pager"><button id="ime-page-prev" class="icon-button" on:click="previousPage"><image class="icon" src="/assets/ime-icons/arrow-left-s-line.png" /></button><button id="ime-page-next" class="icon-button" on:click="nextPage"><image class="icon" src="/assets/ime-icons/arrow-right-s-line.png" /></button></div>
<div class="bottom"><button id="ime-back" class="icon-button" on:click="closePanel"><image class="icon" src="/assets/ime-icons/keyboard-box-line.png" /></button><button id="ime-delete" class="delete icon-button" on:click="backspace"><image class="icon" src="/assets/ime-icons/delete-back-2-line.png" /></button><button id="ime-finish" class="done icon-button" on:click="finish"><image class="icon" src="/assets/ime-icons/check-line.png" /></button></div>
</div>
<div if="panel === 'tools'" class="body tools">
<p class="page-info">{{secret ? '私密输入 · 不学习、不保存' : '光标编辑 · 输入仅保留在本机'}}</p>
<div class="toolrow"><button on:click="cursorLeft">← 左移</button><button on:click="cursorRight">右移 →</button></div>
<div class="toolrow"><button on:click="cursorStart">到开头</button><button on:click="cursorEnd">到末尾</button></div>
<div class="toolrow"><button on:click="newline">换行</button><button on:click="rawInput">拼音上屏</button></div>
<div class="toolrow"><button on:click="toggleLearning">{{learning ? '学习:开' : '学习:关'}}</button><button on:click="clearPreferences">清除学习</button></div>
<div class="toolrow"><button class="danger" on:click="clearText">{{clearArmed ? '确认清空' : '清空全文'}}</button><button on:click="backspace">删除一字</button></div>
<div class="bottom"><button id="ime-back" class="icon-button" on:click="closePanel"><image class="icon" src="/assets/ime-icons/keyboard-box-line.png" /></button><button id="ime-finish" class="done icon-button" on:click="finish"><image class="icon" src="/assets/ime-icons/check-line.png" /></button></div>
</div>
</div>
</div>
</template>
<style>
.ime-surface{display:flex;flex-direction:column;width:100%;height:100%;background-color:#ECEEF2}
.ime{display:flex;flex-direction:column;align-items:stretch;width:100%;height:100%;padding:14px 10px;background-color:#ECEEF2;color:#20232B}
p{margin:0;color:#20232B;font-size:.62rem}button{padding:0;margin:3px;border-radius:12px;color:#20232B;background-color:#FFFFFF;font-size:.65rem;flex-shrink:0}button:active{background-color:#D7E5FF}
.icon-button{display:flex;align-items:center;justify-content:center}.icon{width:24px;height:24px;object-fit:contain}.small-icon{width:20px;height:20px}
.top,.composition,.candidates,.keyrow,.modes,.bottom,.pager,.toolrow{display:flex;flex-direction:row;align-items:center;justify-content:center;flex-shrink:0}
.top{height:27px}.cancel{width:44px;height:27px;background-color:transparent;color:#2867D9;font-size:.53rem}.title{flex:1;text-align:center;font-size:.55rem;color:#737984}.count{width:44px;font-size:.4rem;text-align:right;color:#9297A1}
.preview{height:43px;margin:7px 7px 3px;padding:0 14px;background-color:#FFFFFF;text-align:left;font-size:.82rem;color:#20232B;border-radius:13px}
.composition{height:24px;padding:0 12px}.spelling{flex:1;color:#2867D9;font-size:.55rem}.hint{color:#8B909A;font-size:.38rem;text-align:right}
.body,.keypad,.full-list{display:flex;flex-direction:column;align-items:stretch;min-height:0}.body{flex:1;justify-content:center}.keypad{flex-shrink:0}
.candidates{height:42px;margin:0 4px 4px;background-color:#F8F9FB;border-radius:12px}.candidate{flex:1;min-width:45px;height:36px;background-color:transparent;color:#20232B;font-size:.78rem}.candidate-placeholder{flex:1;text-align:center;color:#A4A9B2;font-size:.45rem}.more{width:46px;height:36px;background-color:transparent;font-size:.5rem;color:#2867D9}
.keyrow{align-items:stretch;align-self:center;width:100%}.key{display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;height:52px;min-width:0;background-color:#FFFFFF;border-radius:12px}.key-label{font-size:.88rem;color:#22252E}.key-sub{font-size:.35rem;color:#9DA2AD;margin-top:1px}
.modes{height:36px;margin-top:3px}.modes button{flex:1;height:31px;background-color:transparent;font-size:.53rem;color:#636A77}
.bottom{height:41px;margin-top:2px}.bottom button{flex:1;height:37px;font-size:.6rem;background-color:#FFFFFF}.bottom .done{background-color:#2867D9;color:#FFFFFF}.bottom .delete{background-color:#D9DDE4;color:#515866}
.page-info{text-align:center;color:#8B919D;font-size:.45rem;margin:5px 0;flex-shrink:0}.full-list{flex:1;justify-content:center}.full-candidate{display:flex;flex-direction:row;align-items:center;justify-content:space-between;height:41px;margin:4px 5px;padding:0 16px;background-color:#FFFFFF}.word{font-size:.8rem;color:#22252E}.reading{font-size:.4rem;color:#A1A7B1;margin-left:7px}.empty{margin:15px 8px;color:#9299A5;text-align:center}
.pager{height:38px}.pager button{flex:1;height:34px;background-color:transparent;color:#2867D9;font-size:.55rem}.toolrow button{flex:1;height:40px;font-size:.57rem}.tools{justify-content:center}.tools .danger{color:#C85656}.tools .bottom{margin-top:8px}
@media (shape:circle){.ime{padding:30px 8px}.top{align-self:center;width:186px;height:24px}.cancel{height:24px}.preview{align-self:center;width:280px;height:32px;margin-top:5px}.composition{align-self:center;width:300px;height:19px}.candidates{align-self:center;width:352px;height:38px}.candidate{height:34px}.more{height:34px}.keypad{align-self:center;width:376px}.key{height:49px}.modes{align-self:center;width:286px;height:33px}.modes button{height:29px}.bottom{align-self:center;width:200px;height:38px}.bottom button{height:34px}.full-list{align-self:center;width:320px}.full-candidate{height:37px;margin:2px 5px}.pager{align-self:center;width:276px}.toolrow{align-self:center;width:300px}.toolrow button{height:35px}.tools .page-info{align-self:center;width:280px}.tools .bottom{margin-top:4px}.full-candidate .word{font-size:.7rem}}
</style>
<script> <script>
import file from '@system.file'
import storage from '@system.storage'
import router from '@system.router' import router from '@system.router'
import {createDictionary,chars,edit,erase,preview,learn,sanitizePreferences,KEY14_GROUPS,display14} from '../common/ime-engine.js' import {activeInputs} from '../common/ime-lifecycle.js'
const LETTERS=['qwertyuiop','asdfghjkl','zxcvbnm']
const SYMBOLS=['!"#$%&',"'()*+,",'-./:;<','=>?@[\\',']^_`{|','}~,。?!']
function keys(rows){return rows.map(row=>Array.from(row).map(value=>({label:value,value:value,sub:''})))}
export default { export default {
data:{initial:'',title:'蟹蟹输入',inputType:'text',maxLength:2048,allowNewline:true,secret:false,text:'',cursor:0,composition:'',compositionDisplay:'',display:'│',count:0,limit:2048,mode:'t9',modeLabel:'九宫拼音',upper:false,symbolPage:0,panel:'keys',keyRows:[],layoutRows:[],keyStyle:'',labelStyle:'',bar:[],visibleCandidates:[],total:0,candidatePage:0,pageCount:1,busy:false,hint:'离线全量词库',learning:false,clearArmed:false}, data:{initial:'',title:'蟹蟹输入',inputType:'text',maxLength:2048,allowNewline:true,inputTheme:'bluewhite',secret:false,revealSecret:false,options:{}},
dictionary:null,preferences:{},allCandidates:[],active:true,life:null,requestId:0,lookupTimer:null,repeatTimer:null,repeatDelay:null,result:null,original:'', result:null,
onInit(){ onInit(){this.options={initial:this.initial,title:this.title,inputType:this.inputType,maxLength:this.maxLength,allowNewline:this.allowNewline,inputTheme:this.inputTheme,secret:this.secret,revealSecret:this.revealSecret}},
this.life={active:true,id:0};this.secret=!!this.secret||this.inputType==='password' onShow(){for(const input of activeInputs())if(input.$page===this.$page)input.onShow()},
this.limit=Math.floor(Math.max(1,Math.min(4096,Number(this.maxLength)||2048))) onHide(){for(const input of activeInputs())if(input.$page===this.$page)input.onHide()},
this.text=chars(this.initial).slice(0,this.limit).join('');this.original=this.text;this.cursor=chars(this.text).length clearPrivate(){for(const input of activeInputs())if(input.$page===this.$page&&input.secret)input.clearPrivate();this.initial='';this.options.initial=''},
try{this.preferences=sanitizePreferences(storage.get('crab.ime.preferences'));this.learning=!this.secret&&storage.get('crab.ime.learning')===true}catch(error){this.preferences={};this.learning=false} complete(result){this.result={accepted:true,text:result.text};router.close(this.$page,this.result)},
this.dictionary=createDictionary(uri=>file.readText({uri})) cancel(){this.result={accepted:false};router.close(this.$page,this.result)},
this.mode=this.inputType==='number'||this.inputType==='pin'?'num':this.inputType==='url'||this.inputType==='password'?'en':'t9' onDestroy(){this.initial='';this.options={};router.close(this.$page,this.result||{accepted:false});this.result=null}
this.renderKeys();this.updateDisplay()
},
onShow(){this.active=true;this.life.active=true;if(this.composition)this.schedule()},
onHide(){this.active=false;this.life.active=false;this.invalidate();this.stopRepeat();if(this.secret)this.clearPrivate()},
clearPrivate(){this.active=false;this.life.active=false;this.invalidate();this.stopRepeat();this.text='';this.initial='';this.original='';this.composition='';this.allCandidates=[];this.bar=[];this.visibleCandidates=[];this.total=0;this.updateDisplay()},
onDestroy(){this.active=false;this.life.active=false;this.invalidate();this.stopRepeat();if(this.dictionary)this.dictionary.clear();this.allCandidates=[];this.preferences={};this.text='';this.initial='';this.original='';router.close(this.$page,this.result||{accepted:false});this.result=null},
invalidate(){this.requestId++;this.life.id=this.requestId;if(this.lookupTimer!==null){clearTimeout(this.lookupTimer);this.lookupTimer=null}this.busy=false},
updateDisplay(){this.count=chars(this.text).length;this.display=preview(this.text,this.cursor,this.secret);this.compositionDisplay=this.mode==='14'?display14(this.composition):this.composition},
renderKeys(){
if(this.mode==='t9')this.keyRows=[[{label:'标点',value:'symbols',sub:'1'},{label:'ABC',value:'2',sub:'2'},{label:'DEF',value:'3',sub:'3'}],[{label:'GHI',value:'4',sub:'4'},{label:'JKL',value:'5',sub:'5'},{label:'MNO',value:'6',sub:'6'}],[{label:'PQRS',value:'7',sub:'7'},{label:'TUV',value:'8',sub:'8'},{label:'WXYZ',value:'9',sub:'9'}]]
else if(this.mode==='num')this.keyRows=keys(['123','456','789','.0-'])
else if(this.mode==='sym')this.keyRows=keys(SYMBOLS.slice(this.symbolPage*3,this.symbolPage*3+3))
else if(this.mode==='14'){const all=KEY14_GROUPS.map((group,index)=>({label:group.toUpperCase(),value:'0123456789abcd'[index],sub:''}));this.keyRows=[all.slice(0,5),all.slice(5,10),all.slice(10)]}
else this.keyRows=keys(LETTERS.map(row=>this.upper&&this.mode==='en'?row.toUpperCase():row))
this.keyStyle='height:'+(this.keyRows.length<=3?'49':this.keyRows.length===4?'38':this.keyRows.length===5?'28':'22')+'px'
const qwerty=this.mode==='py'||this.mode==='en'
this.labelStyle=qwerty?'font-size:22px':this.mode==='14'?'font-size:24px':''
this.layoutRows=this.keyRows.map((row,index)=>({keys:row,style:'width:'+(qwerty?['100','94','84'][index]:index===this.keyRows.length-1?'91':'100')+'%'}))
this.modeLabel={t9:'九宫拼音',py:'26键全拼 · v 代表 ü','14':'14键拼音',en:this.upper?'英文大写':'英文小写',num:'数字',sym:'符号 · '+(this.symbolPage+1)+'/2'}[this.mode]
this.compositionDisplay=this.mode==='14'?display14(this.composition):this.composition
},
setMode(mode){this.stopRepeat();if(this.composition&&mode!==this.mode){if(this.mode==='t9'||mode==='t9'||this.mode==='14'||mode==='14'){this.hint='请先选择候选词再切换';return}if(mode!=='py'&&!this.rawInput())return}this.mode=mode;this.panel='keys';this.renderKeys();this.schedule()},
chinese(){this.setMode(this.mode==='t9'?'py':'t9')},fourteen(){this.setMode('14')},english(){if(this.mode==='en')this.upper=!this.upper;this.setMode('en')},numbers(){this.setMode('num')},symbols(){if(this.mode==='sym'){this.symbolPage=1-this.symbolPage;this.renderKeys()}else{this.symbolPage=0;this.setMode('sym')}},
press(value){this.clearArmed=false;if(value==='symbols'){this.symbols();return}if(this.mode==='t9'||this.mode==='py'||this.mode==='14'){if(this.composition.length>=32){this.hint='每段最多 32 位';return}this.composition+=value.toLowerCase();this.schedule()}else this.insert(value)},
insert(value){
let insertion=String(value)
if(this.inputType==='number')insertion=insertion.replace(/[^0-9.\-]/g,'')
if(this.inputType==='pin')insertion=insertion.replace(/[^0-9]/g,'')
if(this.inputType==='url')insertion=insertion.replace(/\s/g,'')
if(!this.allowNewline)insertion=insertion.replace(/[\r\n]/g,'')
const next=edit(this.text,this.cursor,insertion,this.limit);this.text=next.text;this.cursor=next.cursor;this.updateDisplay();this.hint=next.limited?'已达字数上限':'离线输入'
return !next.limited
},
schedule(){
this.compositionDisplay=this.mode==='14'?display14(this.composition):this.composition
this.invalidate();this.allCandidates=[];this.bar=[];this.visibleCandidates=[];this.total=0;this.pageCount=1;this.candidatePage=0
if(!this.composition||!this.active)return
const id=this.requestId;this.busy=true;this.hint='正在查词…'
this.lookupTimer=setTimeout(()=>{this.lookupTimer=null;this.query(id)},45)
},
async query(id){
const raw=this.composition,life=this.life
try{const result=await this.dictionary.lookup(raw,this.mode==='14'?'14':this.mode==='t9',this.learning?this.preferences:{})
if(!life.active||id!==life.id)return
this.allCandidates=result;this.total=result.length;this.bar=result.slice(0,3);this.pageCount=Math.max(1,Math.ceil(result.length/4));this.pageCandidates();this.hint=result.length?result.length+' 个候选':'暂无匹配 · 可退格修改'
}catch(error){if(life.active&&id===life.id)this.hint='词库暂不可用,可切换英文输入'}
finally{if(life.active&&id===life.id)this.busy=false}
},
pageCandidates(){this.visibleCandidates=this.allCandidates.slice(this.candidatePage*4,this.candidatePage*4+4)},
choose(item){
if(this.busy||!this.composition||!this.allCandidates.some(x=>x.word===item.word&&x.pinyin===item.pinyin&&x.consume===item.consume))return
if(chars(this.text).length+chars(item.word).length>this.limit){this.hint='剩余空间不足,未截断候选词';return}
const raw=this.composition;this.insert(item.word)
if(this.learning&&!this.secret){try{this.preferences=learn(this.preferences,this.mode==='14'?'k14:'+raw:raw,item);storage.set('crab.ime.preferences',this.preferences)}catch(error){this.learning=false}}
this.composition=raw.slice(item.consume);this.panel='keys';this.schedule()
},
expand(){this.panel='candidates';this.pageCandidates()},previousPage(){this.candidatePage=Math.max(0,this.candidatePage-1);this.pageCandidates()},nextPage(){this.candidatePage=Math.min(this.pageCount-1,this.candidatePage+1);this.pageCandidates()},
backspace(){this.clearArmed=false;if(this.composition){this.composition=this.composition.slice(0,-1);this.schedule()}else{const next=erase(this.text,this.cursor);this.text=next.text;this.cursor=next.cursor;this.updateDisplay()}},
startDelete(){this.stopRepeat();this.backspace();this.repeatDelay=setTimeout(()=>{this.repeatDelay=null;this.repeatTimer=setInterval(()=>this.backspace(),120)},450)},
endDelete(){this.stopRepeat()},
stopRepeat(){if(this.repeatTimer!==null){clearInterval(this.repeatTimer);this.repeatTimer=null}if(this.repeatDelay!==null){clearTimeout(this.repeatDelay);this.repeatDelay=null}},
space(){if(this.composition){if(!this.busy&&this.allCandidates.length)this.choose(this.allCandidates[0]);else this.hint='请等待候选或修改拼音'}else this.insert(' ')},
rawInput(){if((this.mode==='t9'||this.mode==='14')&&this.composition){this.hint='编码请先选择候选';return false}if(this.composition){if(!this.insert(this.composition))return false;this.composition='';this.schedule()}return true},
showTools(){this.stopRepeat();this.panel='tools';this.clearArmed=false},closePanel(){this.panel='keys';this.clearArmed=false},
cursorLeft(){this.cursor=Math.max(0,this.cursor-1);this.updateDisplay()},cursorRight(){this.cursor=Math.min(chars(this.text).length,this.cursor+1);this.updateDisplay()},cursorStart(){this.cursor=0;this.updateDisplay()},cursorEnd(){this.cursor=chars(this.text).length;this.updateDisplay()},
newline(){if(this.allowNewline&&this.inputType!=='url'&&this.inputType!=='number'&&!this.secret)this.insert('\n');else this.hint='此输入项不支持换行'},
clearText(){if(!this.clearArmed){this.clearArmed=true;this.hint='再次点击确认清空';return}this.text='';this.cursor=0;this.composition='';this.clearArmed=false;this.schedule();this.updateDisplay()},
toggleLearning(){if(this.secret){this.hint='私密输入不记录学习';return}try{storage.set('crab.ime.learning',!this.learning);this.learning=!this.learning;this.schedule()}catch(error){this.learning=false;this.hint='本机存储不可用,学习已关闭'}},
clearPreferences(){try{storage.delete('crab.ime.preferences');this.preferences={};this.schedule();this.hint='本应用学习记录已清除'}catch(error){this.hint='学习清除失败,请稍后重试'}},
finish(){this.stopRepeat();if(this.composition){this.hint='请先选择候选词或在编辑中上屏拼音';this.panel='keys';return}this.result={accepted:true,text:this.text};router.close(this.$page,this.result)},
cancel(){this.result={accepted:false};router.close(this.$page,this.result)}
} }
</script> </script>
+16 -4
查看文件
@@ -2,9 +2,21 @@
"package": "com.sydxky.rtos.inputmethod", "package": "com.sydxky.rtos.inputmethod",
"name": "蟹蟹输入", "name": "蟹蟹输入",
"icon": "/assets/icon.png", "icon": "/assets/icon.png",
"versionName": "1.1.0", "versionName": "1.2.4",
"versionCode": 10100, "versionCode": 10204,
"features": [], "features": [],
"router": {"entry": "main", "pages": {"main": {"component": "index"}, "keyboard": {"component": "index"}}}, "router": {
"config": {"designWidth": 410} "entry": "main",
"pages": {
"main": {
"component": "index"
},
"keyboard": {
"component": "index"
}
}
},
"config": {
"designWidth": 410
}
} }
@@ -2,15 +2,16 @@ import fs from 'node:fs'
import vm from 'node:vm' import vm from 'node:vm'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import * as engine from '../src/common/ime-engine.js' import * as engine from '../src/common/ime-engine.js'
const ux=fs.readFileSync(new URL('../src/keyboard/index.ux',import.meta.url),'utf8') import {normalizeTheme,themeStyles as stylesForTheme} from '../src/common/ime-theme.js'
const ux=fs.readFileSync(new URL('../src/components/CrabInput/index.ux',import.meta.url),'utf8')
const source=ux.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .+$/gm,'').replace('export default','module.exports=') const source=ux.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .+$/gm,'').replace('export default','module.exports=')
let count=0 let count=0
const closed=[],store=new Map(),timers=new Map();let nextTimer=1,storageFails=false const closed=[],store=new Map(),timers=new Map();let nextTimer=1,storageFails=false
function check(ok,label){assert.ok(ok,label);count++} function check(ok,label){assert.ok(ok,label);count++}
function page(params={}){ function page(params={}){
const module={exports:{}} const module={exports:{}}
vm.runInNewContext(source,{module,console,...engine,router:{close(p,r){closed.push(r)}},storage:{get:k=>store.get(k),set:(k,v)=>{if(storageFails)throw Error('storage full');store.set(k,v)},delete:k=>store.delete(k)},file:{readText:async({uri})=>fs.readFileSync(new URL('../src'+uri,import.meta.url),'utf8')},setTimeout:fn=>{const id=nextTimer++;timers.set(id,fn);return id},clearTimeout:id=>timers.delete(id),setInterval:fn=>{const id=nextTimer++;timers.set(id,fn);return id},clearInterval:id=>timers.delete(id)}) vm.runInNewContext(source,{module,console,...engine,registerInput(){},unregisterInput(){},normalizeTheme,stylesForTheme,router:{close(p,r){closed.push(r)}},storage:{get:k=>store.get(k),set:(k,v)=>{if(storageFails)throw Error('storage full');store.set(k,v)},delete:k=>store.delete(k)},file:{readText:async({uri})=>fs.readFileSync(new URL('../src'+uri,import.meta.url),'utf8')},setTimeout:fn=>{const id=nextTimer++;timers.set(id,fn);return id},clearTimeout:id=>timers.delete(id),setInterval:fn=>{const id=nextTimer++;timers.set(id,fn);return id},clearInterval:id=>timers.delete(id)})
const p={...module.exports,...JSON.parse(JSON.stringify(module.exports.data)),...params};delete p.data;p.onInit();return p const p={$emit:(name,r)=>closed.push(r),...module.exports,...JSON.parse(JSON.stringify(module.exports.data)),...params};delete p.data;p.onInit();return p
} }
let p=page();check(p.mode==='t9'&&!p.learning,'T9 large keys, learning opt-in') let p=page();check(p.mode==='t9'&&!p.learning,'T9 large keys, learning opt-in')
p.chinese();check(p.keyRows.map(row=>row.map(key=>key.value).join('')).join('/')==='qwertyuiop/asdfghjkl/zxcvbnm','26-key pinyin uses standard QWERTY rows') p.chinese();check(p.keyRows.map(row=>row.map(key=>key.value).join('')).join('/')==='qwertyuiop/asdfghjkl/zxcvbnm','26-key pinyin uses standard QWERTY rows')
@@ -29,9 +30,11 @@ p.choose({...hello});check(p.text==='你好'&&!p.composition,'candidate event pa
p.finish();check(closed.at(-1).accepted&&closed.at(-1).text==='你好','finish returns text') p.finish();check(closed.at(-1).accepted&&closed.at(-1).text==='你好','finish returns text')
p=page({initial:'unchanged'});p.insert('!');p.cancel();check(closed.at(-1).accepted===false,'cancel does not return edited text') p=page({initial:'unchanged'});p.insert('!');p.cancel();check(closed.at(-1).accepted===false,'cancel does not return edited text')
p=page({inputType:'password',initial:'😀secret'});p.toggleLearning();check(!p.learning&&!p.display.includes('secret'),'password masked and cannot learn');p.onHide();check(!p.text&&!p.initial&&!p.original,'password clears on hide') p=page({inputType:'password',initial:'😀secret'});p.toggleLearning();check(!p.learning&&!p.display.includes('secret'),'password masked and cannot learn');p.onHide();check(!p.text&&!p.initial&&!p.original,'password clears on hide')
p=page({inputType:'password',revealSecret:true,initial:'Visible123!'});check(p.display.includes('Visible123!')&&p.secret&&!p.learning,'explicit password reveal preserves private input');p.toggleLearning();check(!p.learning,'revealed password cannot enable learning');p.onHide();check(!p.text&&!p.initial&&!p.original,'revealed password clears on hide')
p=page({initial:'你😀好'});p.cursor=2;p.backspace();check(p.text==='你好','UI Unicode deletion') p=page({initial:'你😀好'});p.cursor=2;p.backspace();check(p.text==='你好','UI Unicode deletion')
p.startDelete();check(p.repeatDelay!==null,'delete hold delay starts');p.onHide();check(p.repeatDelay===null&&p.repeatTimer===null,'hide stops repeat timers') p.startDelete();check(p.repeatDelay!==null,'delete hold delay starts');p.onHide();check(p.repeatDelay===null&&p.repeatTimer===null,'hide stops repeat timers')
p=page();let resolve;p.dictionary={lookup:()=>new Promise(yes=>resolve=yes)};p.composition='64';const pending=p.query(p.requestId);p.onHide();resolve([{word:'你',consume:2,pinyin:'ni'}]);await pending;check(p.total===0,'late dictionary response cannot update hidden page') p=page();let resolve;p.dictionary={lookup:()=>new Promise(yes=>resolve=yes)};p.composition='64';const pending=p.query(p.requestId);p.onHide();resolve([{word:'你',consume:2,pinyin:'ni'}]);await pending;check(p.total===0,'late dictionary response cannot update hidden page')
p=page();p.mode='py';p.composition='shi';await p.query(p.requestId);p.expand();check(p.visibleCandidates.length===16&&p.candidateRows.length===4,'expanded single characters use 16 real candidate cells');p.nextPage();check(p.candidatePage===1&&p.visibleCandidates[0]===p.allCandidates[16],'next grid page continues without omissions');check(!ux.includes('item.pinyin}}')&&!ux.includes('class="reading"'),'expanded cells do not display pronunciation');
p=page({maxLength:2,initial:'你'});p.composition='nihao';p.allCandidates=[{word:'你好',consume:5,pinyin:'nihao'}];p.choose(p.allCandidates[0]);check(p.text==='你'&&p.composition==='nihao','candidate is not silently truncated') p=page({maxLength:2,initial:'你'});p.composition='nihao';p.allCandidates=[{word:'你好',consume:5,pinyin:'nihao'}];p.choose(p.allCandidates[0]);check(p.text==='你'&&p.composition==='nihao','candidate is not silently truncated')
p.finish();check(p.composition==='nihao','finish does not silently drop uncommitted composition') p.finish();check(p.composition==='nihao','finish does not silently drop uncommitted composition')
p=page({inputType:'number'});p.insert('12x😀.3');check(p.text==='12.3','number field filters nonnumeric input') p=page({inputType:'number'});p.insert('12x😀.3');check(p.text==='12.3','number field filters nonnumeric input')
@@ -41,4 +44,20 @@ p.english();p.english();check(p.keyRows.flat().some(x=>x.value==='Z'),'uppercase
p=page({initial:'test'});p.clearText();check(p.text==='test'&&p.clearArmed,'destructive clear needs second tap');p.clearText();check(p.text==='','confirmed clear') p=page({initial:'test'});p.clearText();check(p.text==='test'&&p.clearArmed,'destructive clear needs second tap');p.clearText();check(p.text==='','confirmed clear')
p.composition='shi';p.schedule();const owned=p.lookupTimer;p.onDestroy();check(!timers.has(owned)&&p.lookupTimer===null,'destroy releases owned lookup timer') p.composition='shi';p.schedule();const owned=p.lookupTimer;p.onDestroy();check(!timers.has(owned)&&p.lookupTimer===null,'destroy releases owned lookup timer')
p=page();p.learning=true;p.composition='64';p.allCandidates=[{word:'你',pinyin:'ni',consume:2}];storageFails=true;p.choose(p.allCandidates[0]);check(p.text==='你'&&!p.composition&&!p.learning,'storage failure cannot duplicate committed candidate');p.toggleLearning();check(!p.learning,'storage failure leaves learning off');storageFails=false p=page();p.learning=true;p.composition='64';p.allCandidates=[{word:'你',pinyin:'ni',consume:2}];storageFails=true;p.choose(p.allCandidates[0]);check(p.text==='你'&&!p.composition&&!p.learning,'storage failure cannot duplicate committed candidate');p.toggleLearning();check(!p.learning,'storage failure leaves learning off');storageFails=false
p=page({initial:'你好'});p.mode='t9';await p.query(p.requestId);check(p.prediction&&p.bar.some(x=>x.word==='世界'),'component carries 1.2.3 contextual associations');p.choose(p.bar.find(x=>x.word==='世界'));check(p.text==='你好世界','predicted suffix commits without composition');
p=page({initial:'once',config:{session:42}});const before=closed.length;p.finish();p.finish();p.cancel();check(closed.length===before+1&&closed.at(-1).session===42,'component emits one result per mount');p.onDestroy();check(closed.length===before+1,'destroy does not emit a second result');
p=page();p.mode='py';p.composition='niha';await p.query(p.requestId)
p.choose(p.bar[0]);p.invalidate();await p.query(p.requestId)
check(p.text==='你好'&&p.prediction&&p.bar[0].word==='世界','partial spelling commits once and offers context continuation')
p.choose(p.bar[0]);check(p.text==='你好世界'&&!p.composition,'continuation appends only the next word')
p=page();p.mode='py';p.composition='shi';await p.query(p.requestId)
const steadyBar=p.bar,steadyText=p.display;p.composition='shij';p.schedule()
check(p.busy&&p.bar===steadyBar&&p.display===steadyText,'pending lookup retains candidate and preview nodes instead of clearing visible text')
const beforeStale=p.text;p.choose(steadyBar[0]);check(p.text===beforeStale,'retained candidates cannot commit while their replacement is pending')
p=page({initial:'你好'});p.composition='';await p.query(p.requestId);p.space();check(p.text==='你好 '&&!p.text.includes('世界')&&!p.prediction&&!p.bar.length,'space ends the prediction context without auto-committing or leaving stale suggestions')
p=page({initial:'你好'});p.composition='';await p.query(p.requestId);p.newline();check(p.text==='你好\n'&&!p.prediction&&!p.bar.length,'newline ends the prediction context and clears stale suggestions')
p=page({inputType:'password',initial:'你好'});check(p.inputContext()==='','password content is never used as predictive context')
p=page({initial:'不'});check(p.previewBefore==='不'&&p.previewAfter===''&&p.display==='不','entered text is separated from the native thin cursor without a text spacer')
p=page({initial:'你😀好'});p.cursor=2;p.updateDisplay();check(p.previewBefore==='你😀'&&p.previewAfter==='好','moving the cursor updates both preview runs without splitting emoji')
p=page();p.english();p.press('q');p.english();p.press('Q');p.english();p.press('q');check(p.text==='qQq'&&!p.upper,'case switching changes the actual committed English characters in both directions')
console.log(JSON.stringify({suite:'ime-client',assertions:count,status:'passed'})) console.log(JSON.stringify({suite:'ime-client',assertions:count,status:'passed'}))
@@ -0,0 +1,107 @@
"""Native caret/QWERTY fixture using an owned emulator without Inspector."""
import argparse,hashlib,importlib.util,json,math,pathlib,shutil,subprocess,time
APP=pathlib.Path(__file__).resolve().parents[1]
spec=importlib.util.spec_from_file_location('native',APP/'tests/native.py')
native=importlib.util.module_from_spec(spec);spec.loader.exec_module(native)
emu=native.emu;emu.APPS=['inputmethod']
PROBE='''<template><div><p>输入组件原生验收</p></div></template>
<script>
import router from '@system.router'
import tc from '@system.test'
import {activeInputs} from '../common/ime-lifecycle.js'
import {themeStyles} from '../common/ime-theme.js'
export default {started:false,onShow(){if(this.started)return;this.started=true;setTimeout(()=>{
tc.testcase('preview-qwerty',async()=>{
router.push({uri:'/keyboard',params:{initial:'不'}});await tc.wait(.5);
var page=router.getPages().slice(-1)[0],p=activeInputs().filter(input=>input.$page===page)[0];
function check(ok,label){if(!ok)throw new Error(label);console.log('PROBE_CHECK '+label)}
async function rect(id){return await p.$element(id).getBoundingClientRect()}
var cases=[['chinese','不',1,false],['phrase','你好',2,false],['ascii','watch',5,false],['mixed','ab你😀好',4,false],['start','你好',0,false],['middle','你好',1,false],['long','长😀'.repeat(30),30,false],['secret','私密😀',2,true]];
for(var theme of ['bluewhite','blackgreen']){
p.invalidate();p.inputTheme=theme;p.themeStyles=themeStyles(theme);
for(var item of cases){p.invalidate();p.mode='en';p.upper=false;p.secret=item[3];p.revealSecret=false;p.text=item[1];p.cursor=item[2];p.composition='';p.renderKeys();p.updateDisplay();await tc.wait(.15);
console.log('PROBE_CAPTURE '+JSON.stringify({name:theme+'-'+item[0],theme:theme,kind:'caret',before:await rect('ime-preview-before'),caret:await rect('ime-caret'),after:await rect('ime-preview-after'),preview:await rect('ime-preview')}));await tc.wait(1.1);}
for(var mode of ['py','en'])for(var upper of [false,true]){
p.invalidate();p.secret=false;p.text='';p.cursor=0;p.composition='';p.mode=mode;p.upper=upper;p.renderKeys();p.updateDisplay();await tc.wait(.15);
var alphabet='qwertyuiopasdfghjklzxcvbnm',keys=[];
for(var letter of alphabet)keys.push({key:letter,rect:await rect('ime-key-'+letter)});
console.log('PROBE_CAPTURE '+JSON.stringify({name:theme+'-'+mode+'-'+(upper?'upper':'lower'),theme:theme,kind:'qwerty',keys:keys}));await tc.wait(1.1);}
}
p.invalidate();p.secret=false;p.text='';p.cursor=0;p.composition='';p.mode='en';p.upper=false;p.renderKeys();p.updateDisplay();await tc.wait(.2);
await tc.getById('ime-key-q').click();await tc.getById('ime-english').click();await tc.wait(.15);check(p.upper,'uppercase enabled');await tc.getById('ime-key-q').click();await tc.getById('ime-english').click();await tc.wait(.15);check(!p.upper,'lowercase restored');await tc.getById('ime-key-q').click();check(p.text==='qQq','actual native keys commit qQq');
console.log('PROBE_DONE');});tc.run('preview-qwerty');},300)}}
</script>'''
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--work',type=pathlib.Path,required=True);parser.add_argument('--output',type=pathlib.Path,required=True);parser.add_argument('--profiles',nargs='+',default=emu.PROFILES)
args=parser.parse_args();args.output.mkdir(parents=True,exist_ok=True);fixture=args.work/'fixture';fixture.mkdir(parents=True,exist_ok=True)
shutil.copytree(APP/'src',fixture/'src',dirs_exist_ok=True);shutil.copy2(APP/'glyphix.config.js',fixture/'glyphix.config.js')
metadata=json.loads((fixture/'src/manifest.json').read_text(encoding='utf-8'));metadata['router']['entry']='native-probe';metadata['router']['pages']['native-probe']={'component':'index'}
(fixture/'src/manifest.json').write_text(json.dumps(metadata,ensure_ascii=False),encoding='utf-8')
(fixture/'src/native-probe').mkdir(exist_ok=True);(fixture/'src/native-probe/index.ux').write_text(PROBE,encoding='utf-8')
source_hash=hashlib.sha256((APP/'src/components/CrabInput/index.ux').read_bytes()).hexdigest()
assert (fixture/'src/components/CrabInput/index.ux').read_bytes()==(APP/'src/components/CrabInput/index.ux').read_bytes()
shell=shutil.which('pwsh')or'powershell';stable=pathlib.Path(r'C:\Users\herry\AppData\Local\Programs\PowerShell\7\pwsh.exe')
if stable.exists():shell=str(stable)
with (args.output/'fixture-build.log').open('w',encoding='utf-8')as log:
result=subprocess.run([shell,'-NoProfile','-File',str(APP.parent/'build-rtos-game.ps1'),'-ProjectRoot',str(fixture)],stdout=log,stderr=subprocess.STDOUT)
assert result.returncode==0,'See fixture-build.log'
report={'method':'owned native console; production keyboard copied byte-for-byte into an auto-running fixture','production_keyboard_sha256':source_hash,'production_keyboard_path':'src/components/CrabInput/index.ux','production_route_sha256':hashlib.sha256((APP/'src/keyboard/index.ux').read_bytes()).hexdigest(),'inspector_used':False,'cases':[],'profiles':[],'passed':False}
def rectangle(value):
if isinstance(value,dict)and'rect'in value:value=value['rect']
return [value.get('left',value.get('x',0)),value.get('top',value.get('y',0)),value.get('width',value.get('w',0)),value.get('height',value.get('h',0))]
for profile in args.profiles:
image,artifacts=emu.prepare(profile,args.work)
device='generic-watch-'+('466x466'if profile.startswith('round')else'390x450');package=metadata['package']
binary=fixture/'.glyphix-work'/('dist/'+device+'-quickapp/'+package+'/bundle.pkg'if profile.endswith('-quickapp')else'image/'+device+'/apps/'+package+'.pkg')
shutil.copy2(binary,image/'apps'/(package+'.pkg'))
command=[str(emu.ROOT/'temp/glyphix-v0.7.2-windows-x64/bin/glyphix-emu.exe'),'--config','pkgs.db','--target',package]
if profile.startswith('round'):command+=['--width','624','--height','624','--dpi','326','--font-size','32','--round','--surface','surface/surface-466x466.json']
else:command+=['--width','390','--height','450','--dpi','321','--font-size','29']
command+=['--memory-profile','8192','--font-map','font-faces','--font-family','sans-serif']
log_path=args.output/(profile+'-console.log');seen=set();finished=False;captures=0
with log_path.open('w',encoding='utf-8')as log:
process=subprocess.Popen(command,cwd=image,stdin=subprocess.PIPE,stdout=log,stderr=subprocess.STDOUT,creationflags=subprocess.CREATE_NO_WINDOW)
try:
deadline=time.monotonic()+100
while time.monotonic()<deadline and process.poll()is None:
lines=log_path.read_text(encoding='utf-8',errors='replace').splitlines()
for line in lines:
if 'PROBE_CAPTURE 'in line:
try:state=json.JSONDecoder().raw_decode(line.split('PROBE_CAPTURE ',1)[1].lstrip())[0]
except json.JSONDecodeError:continue
if state['name']in seen:continue
seen.add(state['name']);state['profile']=profile
# The vendor console accepts relative screenshot names only.
filename=profile+'-'+state['name']+'.png';process.stdin.write(('screenshot '+filename+'\n').encode());process.stdin.flush()
if state['kind']=='caret':
before,caret,after,preview=[rectangle(state[key])for key in ['before','caret','after','preview']]
state.update(left_gap_px=caret[0]-before[0]-before[2],right_gap_px=after[0]-caret[0]-caret[2])
state['passed']=abs(state['left_gap_px'])<=1 and abs(state['right_gap_px'])<=1 and 0<caret[2]<=2 and preview[0]<=caret[0]and caret[0]+caret[2]<=preview[0]+preview[2]
else:
width=466 if profile.startswith('round')else 390;height=466 if profile.startswith('round')else 450;failures=[]
for key in state['keys']:
x,y,w,h=rectangle(key['rect'])
if w<=0 or h<=0 or x<0 or y<0 or x+w>width or y+h>height:failures.append(key['key'])
if profile.startswith('round')and max(math.hypot(tx-233,ty-233)for tx in [x,x+w]for ty in [y,y+h])>231:failures.append(key['key'])
state.update(failures=failures,passed=len(state['keys'])==26 and not failures)
captures+=1;report['cases'].append(state);native.save(args.output/'caret-results.json',report);print(json.dumps(state,ensure_ascii=False),flush=True)
if 'PROBE_DONE'in line:finished=True
if finished:break
time.sleep(.05)
assert finished and captures==24,'Native fixture did not finish; see '+str(log_path)
assert 'terminated:'not in log_path.read_text(encoding='utf-8',errors='replace'),'Native assertion failed'
for state in report['cases']:
if state['profile']!=profile:continue
filename=profile+'-'+state['name']+'.png';snapshot=image/filename
assert snapshot.is_file() and snapshot.read_bytes().startswith(b'\x89PNG\r\n\x1a\n'),'Screenshot missing: '+filename
shutil.copy2(snapshot,args.output/filename);state['screenshot']=filename
report['profiles'].append({'profile':profile,'native_case_switch_passed':True,'captures':captures,'fixture_binary_sha256':hashlib.sha256(binary.read_bytes()).hexdigest()})
finally:
if process.poll()is None:process.terminate()
process.wait(timeout=10);process.stdin.close()
report['passed']=all(row['passed']for row in report['cases']);native.save(args.output/'caret-results.json',report)
assert report['passed'],'See caret-results.json'
if __name__=='__main__':main()
@@ -2,7 +2,7 @@ import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import {fileURLToPath} from 'node:url' import {fileURLToPath} from 'node:url'
import {createDictionary,digitsOf,code14Of,KEY14_GROUPS,display14,edit,erase,preview,learn,sanitizePreferences,chars} from '../src/common/ime-engine.js' import {createDictionary,digitsOf,code14Of,KEY14_GROUPS,display14,candidatePages,edit,erase,preview,previewParts,learn,sanitizePreferences,chars} from '../src/common/ime-engine.js'
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../src') const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../src')
let count=0 let count=0
function check(ok,label){assert.ok(ok,label);count++} function check(ok,label){assert.ok(ok,label);count++}
@@ -34,6 +34,13 @@ check((await dictionary.lookup(pairInput,'14',{[pairInput]:[{word:pairLast.word,
check((await dictionary.lookup('hello!','14',{})).length===0,'14-key rejects invalid token stream') check((await dictionary.lookup('hello!','14',{})).length===0,'14-key rejects invalid token stream')
check(display14('c3754')==='BN UI GH AS OP'&&display14('c3754'.repeat(6)).length<24,'14-key preview displays bounded paired-letter groups') check(display14('c3754')==='BN UI GH AS OP'&&display14('c3754'.repeat(6)).length<24,'14-key preview displays bounded paired-letter groups')
const many=await dictionary.lookup('shi',false,{}) const many=await dictionary.lookup('shi',false,{})
const grid=candidatePages(many),flatten=pages=>pages.flatMap(rows=>rows.flatMap(row=>row.items.map(entry=>entry.item)))
check(grid[0].length===4&&flatten([grid[0]]).length===16,'single characters form a four by four page')
check(flatten(grid).every((item,index)=>item===many[index]),'grid retains all candidates in their exact original order')
const phrases=[{word:'你好'},{word:'完整词组'},{word:'很长的词组必须能够完整显示而不能被截断'},{word:'😀'}]
const phraseGrid=candidatePages(phrases)
check(flatten(phraseGrid).length===phrases.length&&phraseGrid[0][0].items.length===2,'phrases use wider cells without dropping results')
check(phraseGrid.every(rows=>rows.reduce((sum,row)=>sum+row.units,0)<=4),'long phrases use row units within the page budget')
const original=JSON.parse(fs.readFileSync(root+'/assets/ime-dict/s'+digitsOf('shi')+'.json','utf8')).entries.shi const original=JSON.parse(fs.readFileSync(root+'/assets/ime-dict/s'+digitsOf('shi')+'.json','utf8')).entries.shi
check(original.every(word=>many.some(x=>x.word===word&&x.consume===3)),'every original shi candidate remains accessible') check(original.every(word=>many.some(x=>x.word===word&&x.consume===3)),'every original shi candidate remains accessible')
check(dictionary.cacheSize<=4,'bounded shard cache') check(dictionary.cacheSize<=4,'bounded shard cache')
@@ -48,5 +55,21 @@ check(erase('你😀好',2).text==='你好','Unicode backspace preserves surroga
check(edit('你好',2,'世界',3).limited,'maximum length enforced') check(edit('你好',2,'世界',3).limited,'maximum length enforced')
check(!preview('my-secret',5,true).includes('secret'),'secret preview masked') check(!preview('my-secret',5,true).includes('secret'),'secret preview masked')
check(chars('😀').length===1,'Unicode length counts codepoints') check(chars('😀').length===1,'Unicode length counts codepoints')
check(previewParts('不',1,false).before==='不'&&previewParts('不',1,false).after==='','end cursor has no inserted spacer or font-glyph advance')
check(previewParts('你😀好',2,false).before==='你😀'&&previewParts('你😀好',2,false).after==='好','middle cursor splits at a Unicode codepoint boundary')
check(previewParts('你好',0,false).before===''&&previewParts('你好',0,false).after==='你好','start cursor precedes text without padding')
check(previewParts('a b',2,false).before==='a '&&previewParts('a b',2,false).after==='b','intentional spaces are preserved')
check(!JSON.stringify(previewParts('my-secret',5,true)).includes('secret'),'split preview masks password on both sides')
check(chars(previewParts('长😀'.repeat(1000),1000,false).before).length<=8&&chars(previewParts('长😀'.repeat(1000),1000,false).after).length<=8,'long preview keeps the real cursor inside round-screen width')
dictionary.clear();check(dictionary.cacheSize===0,'cache release') dictionary.clear();check(dictionary.cacheSize===0,'cache release')
check((await dictionary.lookup('niha',false,{}))[0].word==='你好','unfinished pinyin completes a real word before shorter fallback')
check((await dictionary.lookup(digitsOf('niha'),true,{}))[0].word==='你好','unfinished T9 shares the completion policy')
check((await dictionary.lookup(code14Of('niha'),'14',{})).some(x=>x.word==='你好'&&x.consume===4&&x.completion),'14-key completion consumes only the actual input tokens')
check((await dictionary.lookup('beij',false,{}))[0].word==='北京','word frequency places common completion before rare homophones')
const continuation=await dictionary.predict('你好')
check(continuation[0].word==='世界'&&continuation.every(x=>x.consume===0)&&continuation.length<=8,'post-commit continuations use the longest context with bounded results')
check((await dictionary.predict('你好。')).length===0&&(await dictionary.predict('secret123')).length===0,'punctuation and non-Chinese contexts end prediction')
check((await dictionary.predict('前文你好'))[0].word==='世界','prediction uses only the bounded suffix before the cursor')
check((await dictionary.lookup('shijie',false,{}))[0].word==='世界','common full-pinyin homophone is ranked by licensed word frequency')
check(dictionary.cacheSize<=4,'prediction and completion retain the bounded cache')
console.log(JSON.stringify({suite:'ime-engine',assertions:count,keys:meta.keys,candidates:meta.candidates,status:'passed'})) console.log(JSON.stringify({suite:'ime-engine',assertions:count,keys:meta.keys,candidates:meta.candidates,status:'passed'}))
+11 -2
查看文件
@@ -17,12 +17,13 @@ def inspect(path,profile):
visible_pages(frame['widgets']) visible_pages(frame['widgets'])
page=pages[-1] page=pages[-1]
circle=profile.startswith('round');width=466 if circle else 390;height=466 if circle else 450 circle=profile.startswith('round');width=466 if circle else 390;height=466 if circle else 450
failures=[];buttons=[];icons=[] failures=[];buttons=[];icons=[];regions={}
def visit(node,ancestors=(),px=0,py=0,visible=True): def visit(node,ancestors=(),px=0,py=0,visible=True):
g=node['geometry'];x=px+g['x'];y=py+g['y'];w=g['w'];h=g['h'] g=node['geometry'];x=px+g['x'];y=py+g['y'];w=g['w'];h=g['h']
visible=visible and node.get('visible',True) visible=visible and node.get('visible',True)
if not visible:return if not visible:return
box=[x,y,w,h];kind=node['class'];identifier=node.get('id','') box=[x,y,w,h];kind=node['class'];identifier=node.get('id','')
if identifier in ['ime-preview','ime-composition','ime-keypad','ime-candidate-strip']:regions[identifier]=box
if kind in ['Button','ImageBox','Label']: if kind in ['Button','ImageBox','Label']:
for ancestor in ancestors: for ancestor in ancestors:
ax,ay,aw,ah=ancestor['box'] ax,ay,aw,ah=ancestor['box']
@@ -36,4 +37,12 @@ def inspect(path,profile):
(buttons if kind=='Button'else icons).append(record) (buttons if kind=='Button'else icons).append(record)
for child in node.get('children',[]):visit(child,ancestors+({'class':kind,'id':identifier,'box':box},),x,y,visible) for child in node.get('children',[]):visit(child,ancestors+({'class':kind,'id':identifier,'box':box},),x,y,visible)
visit(page) visit(page)
return {'page':page.get('id'),'buttons':len(buttons),'icons':len(icons),'max_button_radius':round(max([b['radius']or 0 for b in buttons],default=0),2),'failures':failures,'passed':not failures} gaps={}
if 'ime-composition' in regions and 'ime-keypad' in regions:
compose=regions['ime-composition'];keys=regions['ime-keypad']
gap=keys[1]-compose[1]-compose[3];gaps['composition_to_keys']=gap
# Account for the actual visible strip; direct entry may leave at most
# one rounding pixel, never an unused candidate-sized band.
maximum=(48 if circle else 53)if 'ime-candidate-strip' in regions else 1
if gap < -1 or gap > maximum:failures.append({'reason':'extra gap before keyboard','gap':gap,'maximum':maximum})
return {'page':page.get('id'),'buttons':len(buttons),'icons':len(icons),'gaps':gaps,'max_button_radius':round(max([b['radius']or 0 for b in buttons],default=0),2),'failures':failures,'passed':not failures}
+13 -11
查看文件
@@ -45,27 +45,29 @@ def main():
if not entry['passed']:raise RuntimeError('Native failure '+case+': '+str(logs[-4:])) if not entry['passed']:raise RuntimeError('Native failure '+case+': '+str(logs[-4:]))
if not geometry['passed']:raise RuntimeError('Clipped native layout '+case+': '+str(geometry['failures'][:3])) if not geometry['passed']:raise RuntimeError('Clipped native layout '+case+': '+str(geometry['failures'][:3]))
try: try:
emu.evaluate("var router=require('@system.router').default,tc=require('@system.test').default,launch=require('@system.launch').default,root=router.getPage(0),p=null;function check(ok,m){if(!ok)throw new Error(m);console.log('IME_CHECK '+m)};async function click(t){var ids={'取消':'ime-cancel','空格':'ime-space','删除':'ime-delete','完成':'ime-finish','更多':'ime-expand','编辑':'ime-tools','上一页':'ime-page-prev','下一页':'ime-page-next','键盘':'ime-back','返回键盘':'ime-back'};if(ids[t]){await tc.getById(ids[t]).click();return}var pattern=t.split('').map(function(c){return String.fromCharCode(92)+'u'+('0000'+c.charCodeAt(0).toString(16)).slice(-4)}).join('');await tc.getHasText(new RegExp('^'+pattern+'$')).click()}") emu.evaluate("var router=require('@system.router').default,tc=require('@system.test').default,launch=require('@system.launch').default,root=router.getPage(0),p=null;function check(ok,m){if(!ok)throw new Error(m);console.log('IME_CHECK '+m)};async function click(t){await tc.wait(.35);var ids={'取消':'ime-cancel','空格':'ime-space','删除':'ime-delete','完成':'ime-finish','更多':'ime-expand','编辑':'ime-tools','上一页':'ime-page-prev','下一页':'ime-page-next','键盘':'ime-back','返回键盘':'ime-back'};if(ids[t]){await tc.getById(ids[t]).click();await tc.wait(.35);return}var pattern=t.split('').map(function(c){return String.fromCharCode(92)+'u'+('0000'+c.charCodeAt(0).toString(16)).slice(-4)}).join('');await tc.getHasText(new RegExp('^'+pattern+'$')).click();await tc.wait(.35)}")
emu.evaluate("async function ready(){var end=Date.now()+5000;while(p.busy&&Date.now()<end)await tc.wait(.05);check(!p.busy,'dictionary settled')};function reset(){p.invalidate();p.text='';p.cursor=0;p.composition='';p.allCandidates=[];p.bar=[];p.total=0;p.panel='keys';p.mode='t9';p.limit=2048;p.renderKeys();p.updateDisplay()}") emu.evaluate("async function ready(){var end=Date.now()+5000;while(p.busy&&Date.now()<end)await tc.wait(.05);check(!p.busy,'dictionary settled');await tc.wait(.35)};function reset(){p.invalidate();p.text='';p.cursor=0;p.composition='';p.allCandidates=[];p.bar=[];p.total=0;p.panel='keys';p.mode='t9';p.limit=2048;p.renderKeys();p.updateDisplay()}")
run('auto-open',"await tc.getByClass('document').click();await tc.wait(.4);p=router.getPage(1);check(router.getLength()===2&&p.mode==='t9','field opens full screen T9');root.editText();check(router.getLength()===2,'duplicate opener guarded');") run('auto-open',"await tc.getByClass('document').click();await tc.wait(.4);p=require('pkg://com.sydxky.rtos.inputmethod/common/ime-lifecycle.js').activeInputs().filter(input=>input.$page===router.getPage(1))[0];check(router.getLength()===2&&p.mode==='t9','field opens full screen T9');root.editText();check(router.getLength()===2,'duplicate opener guarded');")
run('t9-phrase',"for(var label of ['MNO','GHI','GHI','ABC','MNO'])await click(label);await ready();check(p.bar[0].word==='你好','frequency ranks common phrase first');await click('你好');check(p.text==='你好'&&!p.composition,'native candidate commits phrase');") run('t9-phrase',"for(var label of ['MNO','GHI','GHI','ABC','MNO'])await click(label);await ready();check(p.bar[0].word==='你好','frequency ranks common phrase first');await click('你好');await ready();check(p.text==='你好'&&!p.composition,'native candidate commits phrase');")
run('continuous',"reset();p.mode='py';p.renderKeys();p.composition='nihaoshijie';p.schedule();await ready();check(p.allCandidates.some(x=>x.word==='你好'&&x.consume===5),'prefix phrase retained');await click('你好');await ready();check(p.composition==='shijie','only matched pinyin consumed');await click('世界');check(p.text==='你好世界'&&!p.composition,'remaining syllables compose');") run('continuous',"reset();p.mode='py';p.renderKeys();p.composition='nihaoshijie';p.schedule();await ready();check(p.allCandidates.some(x=>x.word==='你好'&&x.consume===5),'prefix phrase retained');await click('你好');await ready();check(p.composition==='shijie','only matched pinyin consumed');await click('世界');check(p.text==='你好世界'&&!p.composition,'remaining syllables compose');")
run('complete-candidates',"reset();p.mode='py';p.renderKeys();p.composition='shi';p.schedule();await ready();var total=p.total;await click('更多');check(p.visibleCandidates.length===4&&p.pageCount>5,'full list paginated');for(var i=1;i<p.pageCount;i++)await click('下一页');check(p.candidatePage===p.pageCount-1,'last candidate page accessible');") run('completion-prediction',"reset();p.mode='py';p.renderKeys();for(var letter of ['n','i','h','a'])await click(letter);await ready();check(p.bar[0].word==='你好'&&p.bar[0].consume===4,'unfinished spelling completes a real phrase');await click('你好');await ready();check(p.prediction&&p.bar[0].word==='世界','committed word offers bounded context continuation');await click('世界');check(p.text==='你好世界'&&!p.composition,'prediction appends once without repeating the prefix');")
run('steady-candidates',"reset();p.mode='py';p.renderKeys();p.composition='shi';p.schedule();await ready();var previous=p.bar[0].word,previewBefore=p.display;p.press('j');check(p.busy&&p.bar[0].word===previous&&p.display===previewBefore,'pending result retains preview and candidates');p.choose(p.bar[0]);check(!p.text,'stale retained candidate cannot commit');await ready();")
run('complete-candidates',"reset();p.mode='py';p.renderKeys();p.composition='shi';p.schedule();await ready();var total=p.total;await click('更多');check(p.visibleCandidates.length===16&&p.candidateRows.length===4&&p.pageCount>1,'full list paginated');for(var i=1;i<p.pageCount;i++)await click('下一页');check(p.candidatePage===p.pageCount-1,'last candidate page accessible');")
run('candidate-select',"var last=p.visibleCandidates[p.visibleCandidates.length-1];await click(last.word);check(p.text===last.word,'last original candidate can commit');") run('candidate-select',"var last=p.visibleCandidates[p.visibleCandidates.length-1];await click(last.word);check(p.text===last.word,'last original candidate can commit');")
run('full-pinyin',"reset();await click('26键');check(p.keyRows.map(row=>row.map(key=>key.value).join('')).join('/')==='qwertyuiop/asdfghjkl/zxcvbnm','standard 10-9-7 QWERTY order');for(var letter of ['s','h','u','r','u','f','a'])await click(letter);await ready();check(p.bar.some(x=>x.word==='输入法'),'long phrase in full dictionary');await click('输入法');check(p.text==='输入法','native full pinyin commit');") run('full-pinyin',"reset();await click('26键');check(p.keyRows.map(row=>row.map(key=>key.value).join('')).join('/')==='qwertyuiop/asdfghjkl/zxcvbnm','standard 10-9-7 QWERTY order');for(var letter of ['s','h','u','r','u','f','a'])await click(letter);await ready();check(p.bar.some(x=>x.word==='输入法'),'long phrase in full dictionary');await click('输入法');check(p.text==='输入法','native full pinyin commit');")
run('14-key-phrase',"reset();await click('14键');check(p.mode==='14'&&p.keyRows.flat().length===14,'14-key mode has fourteen real buttons');for(var label of ['BN','UI','GH','AS','OP'])await click(label);await ready();check(p.bar.some(x=>x.word==='你好')&&p.allCandidates.some(x=>x.word==='不好'),'paired ambiguity retains both valid phrases');await click('完成');check(router.getLength()===2,'14-key uncommitted input blocks finish');await click('123');check(p.mode==='14'&&p.composition==='c3754','14-key mode switch preserves composition');await click('你好');check(p.text==='你好'&&!p.composition,'native 14-key phrase commit');") run('14-key-phrase',"reset();await click('14键');check(p.mode==='14'&&p.keyRows.flat().length===14,'14-key mode has fourteen real buttons');for(var label of ['BN','UI','GH','AS','OP'])await click(label);await ready();check(p.bar.some(x=>x.word==='你好')&&p.allCandidates.some(x=>x.word==='不好'),'paired ambiguity retains both valid phrases');await click('完成');check(router.getLength()===2,'14-key uncommitted input blocks finish');await click('123');check(p.mode==='14'&&p.composition==='c3754','14-key mode switch preserves composition');await click('你好');await ready();check(p.text==='你好'&&!p.composition,'native 14-key phrase commit');")
run('14-key-continuous',"reset();await click('14键');for(var label of ['BN','UI','GH','AS','OP','AS','GH','UI','JK','UI','ER'])await click(label);await ready();await click('你好');await ready();check(p.composition.length===6,'14-key consumes five matched keys only');await click('世界');check(p.text==='你好世界'&&!p.composition,'14-key continuous phrase commit');") run('14-key-continuous',"reset();await click('14键');for(var label of ['BN','UI','GH','AS','OP','AS','GH','UI','JK','UI','ER'])await click(label);await ready();await click('你好');await ready();check(p.composition.length===6,'14-key consumes five matched keys only');await click('世界');check(p.text==='你好世界'&&!p.composition,'14-key continuous phrase commit');")
run('14-key-candidates',"reset();await click('14键');for(var label of ['AS','GH','UI'])await click(label);await ready();check(p.total>20,'14-key full dictionary candidates');await click('更多');check(p.visibleCandidates.length===4,'14-key first full candidate page');await click('下一页');check(p.candidatePage===1,'14-key native next candidate page');await click('上一页');check(p.candidatePage===0,'14-key native previous candidate page');var selected=p.visibleCandidates[1];await click(selected.word);check(p.text===selected.word,'14-key expanded candidate commit');") run('14-key-candidates',"reset();await click('14键');for(var label of ['AS','GH','UI'])await click(label);await ready();check(p.total>20,'14-key full dictionary candidates');await click('更多');check(p.visibleCandidates.length<=16&&p.visibleCandidates.length>4,'14-key adaptive candidate grid');await click('下一页');check(p.candidatePage===1,'14-key native next candidate page');await click('上一页');check(p.candidatePage===0,'14-key native previous candidate page');var selected=p.visibleCandidates[1];await click(selected.word);check(p.text===selected.word,'14-key expanded candidate commit');")
run('english',"reset();await click('abc');check(p.keyRows.map(row=>row.map(key=>key.value).join('')).join('/')==='qwertyuiop/asdfghjkl/zxcvbnm','English follows standard QWERTY');await click('abc');await click('Z');await click('ABC');await click('a');check(p.text==='Za','uppercase and lowercase native keys');") run('english',"reset();await click('abc');check(p.keyRows.map(row=>row.map(key=>key.value).join('')).join('/')==='qwertyuiop/asdfghjkl/zxcvbnm','English follows standard QWERTY');await click('大写');await click('Z');await click('小写');await click('a');check(p.text==='Za','uppercase and lowercase native keys');")
run('symbols',"reset();await click('符号');for(var symbol of ['@','%','&','?',',','。']){if(!p.keyRows.some(row=>row.some(key=>key.value===symbol)))await click('符号');await click(symbol)}check(p.text==='@%&?,。','ASCII and Chinese punctuation native keys');") run('symbols',"reset();await click('符号');for(var symbol of ['@','%','&','?',',','。']){if(!p.keyRows.some(row=>row.some(key=>key.value===symbol)))await click('符号');await click(symbol)}check(p.text==='@%&?,。','ASCII and Chinese punctuation native keys');")
run('cursor-unicode',"reset();p.text='你😀好';p.cursor=2;p.updateDisplay();await click('编辑');await click('删除一字');check(p.text==='你好','Unicode-safe deletion');await click('到开头');await click('返回键盘');await click('abc');await click('a');check(p.text==='a你好','insertion at moved cursor');") run('cursor-unicode',"reset();p.text='你😀好';p.cursor=2;p.updateDisplay();await click('编辑');await click('删除一字');check(p.text==='你好','Unicode-safe deletion');await click('到开头');await click('返回键盘');await click('abc');await click('a');check(p.text==='a你好','insertion at moved cursor');")
run('hold-delete',"reset();p.mode='en';p.renderKeys();p.text='abcdefghijklmnop';p.cursor=16;p.updateDisplay();await tc.wait(.25);await tc.getById('ime-delete').scroll(0,0,.95);await tc.wait(.1);check(p.text.length<15,'hold repeats delete');check(p.repeatTimer===null&&p.repeatDelay===null,'release stops repeats');") run('hold-delete',"reset();p.mode='en';p.renderKeys();p.text='abcdefghijklmnop';p.cursor=16;p.updateDisplay();await tc.wait(.25);await tc.getById('ime-delete').scroll(0,0,.95);await tc.wait(.1);check(p.text.length<15,'hold repeats delete');check(p.repeatTimer===null&&p.repeatDelay===null,'release stops repeats');")
run('length-and-mode',"reset();p.limit=2;p.text='你';p.cursor=1;p.mode='py';p.renderKeys();p.composition='nihao';p.schedule();await ready();await click('你好');check(p.text==='你'&&p.composition==='nihao','overlong candidate not truncated');await click('完成');check(router.getLength()===2,'uncommitted composition not dropped');p.mode='t9';p.composition='64';p.renderKeys();p.english();check(p.mode==='t9'&&p.composition==='64','mode switch preserves uncommitted composition');") run('length-and-mode',"reset();p.limit=2;p.text='你';p.cursor=1;p.mode='py';p.renderKeys();p.composition='nihao';p.schedule();await ready();await click('你好');check(p.text==='你'&&p.composition==='nihao','overlong candidate not truncated');await click('完成');check(router.getLength()===2,'uncommitted composition not dropped');p.mode='t9';p.composition='64';p.renderKeys();p.english();check(p.mode==='t9'&&p.composition==='64','mode switch preserves uncommitted composition');")
run('learning',"reset();await click('编辑');check(!p.learning,'learning defaults off');await click('学习:关');check(p.learning,'learning opt-in');await click('返回键盘');p.composition='64';p.schedule();await ready();await click(p.bar[1].word);check(Object.keys(p.preferences).length>0,'only chosen candidate learned');await click('编辑');await click('清除学习');check(Object.keys(p.preferences).length===0,'learning cleared');await click('学习:开');await click('返回键盘');") run('learning',"reset();await click('编辑');check(!p.learning,'learning defaults off');await click('学习:关');check(p.learning,'learning opt-in');await click('返回键盘');p.composition='64';p.schedule();await ready();await click(p.bar[1].word);check(Object.keys(p.preferences).length>0,'only chosen candidate learned');await click('编辑');await click('清除学习');check(Object.keys(p.preferences).length===0,'learning cleared');await click('学习:开');await click('返回键盘');")
run('cancel',"p.text='discard';p.composition='';await click('取消');await tc.wait(.4);check(root.text===''&&router.getLength()===1,'cancel keeps original document');") run('cancel',"p.text='discard';p.composition='';await click('取消');await tc.wait(.4);check(root.text===''&&router.getLength()===1,'cancel keeps original document');")
run('numeric',"await click('数字输入');await tc.wait(.3);p=router.getPage(1);check(p.mode==='num','numeric field auto keyboard');for(var digit of ['1','2','3','0'])await click(digit);await click('完成');await tc.wait(.3);check(root.status.indexOf('1230')>=0,'numeric result returned');") run('numeric',"await click('数字输入');await tc.wait(.3);p=require('pkg://com.sydxky.rtos.inputmethod/common/ime-lifecycle.js').activeInputs().filter(input=>input.$page===router.getPage(1))[0];check(p.mode==='num','numeric field auto keyboard');for(var digit of ['1','2','3','0'])await click(digit);await click('完成');await tc.wait(.3);check(root.status.indexOf('1230')>=0,'numeric result returned');")
run('password-privacy',"await click('私密输入');await tc.wait(.3);p=router.getPage(1);await click('s');await click('e');check(p.secret&&p.display.indexOf('s')<0&&!p.learning,'secret preview masked');await click('编辑');await click('学习:关');check(!p.learning,'secret cannot enable learning');await launch.launch('com.sydxky.rtos.imefixture');await tc.wait(.9);check(!p.active&&!p.text&&!p.initial,'secret erased on actual app switch');await tc.wait(2);await click('取消');") run('password-privacy',"await click('私密输入');await tc.wait(.3);p=require('pkg://com.sydxky.rtos.inputmethod/common/ime-lifecycle.js').activeInputs().filter(input=>input.$page===router.getPage(1))[0];await click('s');await click('e');check(p.secret&&p.display.indexOf('s')<0&&!p.learning,'secret preview masked');await click('编辑');await click('学习:关');check(!p.learning,'secret cannot enable learning');await launch.launch('com.sydxky.rtos.imefixture');await tc.wait(.9);check(!p.$valid||!p.active&&!p.text&&!p.initial,'secret erased on actual app switch');await tc.wait(2);await click('取消');")
run('long-text-clear',"await click('开始输入');await tc.wait(.3);p=router.getPage(1);p.text='长😀'.repeat(1000);p.cursor=2000;p.updateDisplay();check(p.count===2000&&p.display.length<60,'long text preview bounded');await click('编辑');await click('清空全文');check(p.count===2000,'clear needs confirmation');await click('确认清空');check(p.count===0,'confirmed clear');await click('返回键盘');") run('long-text-clear',"await click('开始输入');await tc.wait(.3);p=require('pkg://com.sydxky.rtos.inputmethod/common/ime-lifecycle.js').activeInputs().filter(input=>input.$page===router.getPage(1))[0];p.text='长😀'.repeat(1000);p.cursor=2000;p.updateDisplay();check(p.count===2000&&p.display.length<60,'long text preview bounded');await click('编辑');await click('清空全文');check(p.count===2000,'clear needs confirmation');await click('确认清空');check(p.count===0,'confirmed clear');await click('返回键盘');")
run('lookup-performance',"reset();var times=[];for(var word of ['nihao','zhongguo','shijie','shurufa','beijing','tianqi','shanghai','gongzuo']){var now=Date.now();var candidates=await p.dictionary.lookup(word,false,{});times.push(Date.now()-now);check(candidates.length>0,'lookup '+word)}check(p.dictionary.cacheSize<=4,'bounded decoded shard cache');check(Math.max.apply(null,times)<1000,'emulator lookup under one second');console.log('IME_TIMING '+JSON.stringify(times));") run('lookup-performance',"reset();var times=[];for(var word of ['nihao','zhongguo','shijie','shurufa','beijing','tianqi','shanghai','gongzuo']){var now=Date.now();var candidates=await p.dictionary.lookup(word,false,{});times.push(Date.now()-now);check(candidates.length>0,'lookup '+word)}check(p.dictionary.cacheSize<=4,'bounded decoded shard cache');check(Math.max.apply(null,times)<1000,'emulator lookup under one second');console.log('IME_TIMING '+JSON.stringify(times));")
run('finish-save',"reset();p.insert('你好,RTOS!');await click('完成');await tc.wait(.4);check(root.text==='你好,RTOS!'&&router.getLength()===1,'finished text returned to opener');await click('保存草稿');check(root.saved,'explicit local draft save');") run('finish-save',"reset();p.insert('你好,RTOS!');await click('完成');await tc.wait(.4);check(root.text==='你好,RTOS!'&&router.getLength()===1,'finished text returned to opener');await click('保存草稿');check(root.saved,'explicit local draft save');")
finally: finally:
@@ -0,0 +1,54 @@
import fs from 'node:fs'
import path from 'node:path'
import {fileURLToPath} from 'node:url'
import assert from 'node:assert/strict'
import {syncInput,checkInput,watchInput} from '../scripts/install.mjs'
const parent=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../.glyphix-work')
fs.mkdirSync(parent,{recursive:true})
const scratch=fs.mkdtempSync(path.join(parent,'sync-test-')),source=path.join(scratch,'source'),host=path.join(scratch,'host')
function write(root,name,body){const file=path.join(root,name);fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,body)}
function fixture(root){
write(root,'src/manifest.json',JSON.stringify({versionName:'fixture',router:{pages:{}}}))
for(const name of ['keyboard/index.ux','components/CrabInput/index.ux','assets/ime-icons/icon.png',...['engine','theme','lifecycle'].map(n=>'common/ime-'+n+'.js')])write(root,'src/'+name,'first')
write(root,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['2222']}));write(root,'src/assets/ime-dict/2222.json','{}')
for(const name of ['crabKeyboard','jieba','remix-icon'])write(root,'third_party/'+name+'/LICENSE','MIT')
write(root,'LICENSE','MIT');write(root,'NOTICE.md','third party notices')
}
let stop
try{
fixture(source);write(host,'src/manifest.json',JSON.stringify({router:{pages:{}}}));write(host,'src/assets/host-data.txt','preserve')
write(host,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['1111']}));write(host,'src/assets/ime-dict/1111.json','old format')
const first=syncInput(host,source);assert.equal(first.copied,13);assert.equal(first.removed,1);checkInput(host,source)
const timestamp=fs.statSync(path.join(host,'src/components/CrabInput/index.ux')).mtimeMs
assert.equal(syncInput(host,source).copied,0);assert.equal(fs.statSync(path.join(host,'src/components/CrabInput/index.ux')).mtimeMs,timestamp)
write(host,'src/common/ime-theme.js','stale');assert.throws(()=>checkInput(host,source),/differs/);assert.equal(syncInput(host,source).copied,1)
write(source,'src/common/ime-engine.js','second');assert.throws(()=>checkInput(host,source),/changed/)
write(host,'input-component.json',JSON.stringify({schema:1,integration:'component'}));syncInput(host,source)
assert.ok(!JSON.parse(fs.readFileSync(path.join(host,'src/manifest.json'),'utf8')).router.pages.keyboard)
assert.ok(!fs.existsSync(path.join(host,'src/keyboard/index.ux')))
write(source,'src/keyboard/index.ux','unused compatibility route update');checkInput(host,source)
const events=[];stop=watchInput(host,source,event=>events.push(event))
const initial=events.length
write(source,'src/components/CrabInput/index.ux','component update')
write(source,'src/common/ime-theme.js','theme update')
write(source,'src/assets/ime-icons/new-icon.png','new icon')
write(source,'src/common/ime-layout.js','new related module')
write(source,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['3333'],association_shards:['a0f'],completion_shards:['c01']}));write(source,'src/assets/ime-dict/3333.json','{"changed":true}');write(source,'src/assets/ime-dict/a0f.json','{}');write(source,'src/assets/ime-dict/c01.json','{}')
const deadline=Date.now()+8000
while(Date.now()<deadline){await new Promise(resolve=>setTimeout(resolve,50));try{checkInput(host,source);break}catch{}}
const current=checkInput(host,source)
assert.ok(fs.existsSync(path.join(host,'src/assets/ime-dict/a0f.json'))&&fs.existsSync(path.join(host,'src/assets/ime-dict/c01.json')),'prediction shards synchronize')
assert.notEqual(current.sourceHash,first.sourceHash);assert.ok(events.length>initial)
assert.equal(fs.readFileSync(path.join(host,'src/common/ime-layout.js'),'utf8'),'new related module')
assert.ok(!fs.existsSync(path.join(host,'src/assets/ime-dict/2222.json')))
assert.equal(fs.readFileSync(path.join(host,'src/assets/host-data.txt'),'utf8'),'preserve')
const receipt=JSON.parse(fs.readFileSync(path.join(host,'src/assets/crab-input-source.json'),'utf8'))
receipt.files['../outside']={};write(host,'src/assets/crab-input-source.json',JSON.stringify(receipt))
assert.throws(()=>syncInput(host,source),/Invalid managed/)
assert.ok(!fs.existsSync(path.join(host,'src/assets/crab-input-source.json')),'failed sync invalidates receipt')
console.log(JSON.stringify({suite:'input-sync',status:'passed',watchEvents:events.length,checks:['incremental','tamper rejection','source drift rejection','watch component engine theme icon dictionary update','obsolete shard removal','host isolation','invalid receipt rejection']}))
}finally{
if(stop)stop()
if(!scratch.startsWith(parent+path.sep+'sync-test-'))throw Error('Unexpected test directory')
fs.rmSync(scratch,{recursive:true,force:true})
}
+3 -1
查看文件
@@ -50,11 +50,13 @@ def main():
try: try:
capture(profile,'home') capture(profile,'home')
emu.evaluate("var r=require('@system.router').default,t=require('@system.test').default;var root=r.getPage(0);t.getByClass('document').click()") emu.evaluate("var r=require('@system.router').default,t=require('@system.test').default;var root=r.getPage(0);t.getByClass('document').click()")
time.sleep(1);emu.evaluate("var p=r.getPage(1);p.composition='64426';p.schedule()") time.sleep(1);emu.evaluate("var p=require('pkg://com.sydxky.rtos.inputmethod/common/ime-lifecycle.js').activeInputs().filter(input=>input.$page===r.getPage(1))[0];p.composition='64426';p.schedule()")
time.sleep(.5);capture(profile,'t9') time.sleep(.5);capture(profile,'t9')
emu.evaluate("p.composition='shi';p.mode='py';p.renderKeys();p.schedule()") emu.evaluate("p.composition='shi';p.mode='py';p.renderKeys();p.schedule()")
time.sleep(.5);capture(profile,'pinyin') time.sleep(.5);capture(profile,'pinyin')
emu.evaluate("p.expand()");capture(profile,'candidates') emu.evaluate("p.expand()");capture(profile,'candidates')
emu.evaluate("p.closePanel();p.composition='nihao';p.schedule()")
time.sleep(.5);emu.evaluate("p.expand()");capture(profile,'phrase-candidates')
emu.evaluate("p.showTools()");capture(profile,'tools') emu.evaluate("p.showTools()");capture(profile,'tools')
emu.evaluate("p.composition='';p.setMode('14');p.composition='c3754';p.schedule()") emu.evaluate("p.composition='';p.setMode('14');p.composition='c3754';p.schedule()")
time.sleep(.5);capture(profile,'14-key') time.sleep(.5);capture(profile,'14-key')
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 32 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 37 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 38 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 37 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 38 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 36 KiB

-11
查看文件
@@ -1,11 +0,0 @@
# 1.1.0独立开源树验收
2026-11-14,重新从独立公开源码生成完整词库、执行逻辑回归并构建普通/QuickApp圆屏和方屏四目标。未导出内部主仓库历史、其他业务应用、服务端代码、凭据或SDK。
- 原始41个词库文件不变;两种索引分别回读验证全部104,837组索引及163,238候选项,6779分片,最大116,686字节。
- 40项引擎断言、29项UX脚本断言通过;四配置构建通过。
- 独立源码原生操作84/84、操作后布局84/84、视觉状态44/44通过,内存8192 KiB。
- 使用本仓库最小离线参考页进行真实应用切换;参考页从自己的应用上下文启动输入应用返回,验证私密输入在切换时清除。没有调用输入页生命周期来代替真实切换。
- 完整结果与实际运行包SHA-256见`docs/public-validation.json`;截图/控件树在发行版证据ZIP中。几何检查包含有效可见父级与全部父容器裁切、圆屏安全区和指定边缘像素采样。
MIT允许商用,须保留许可及原作者署名;Remix Icon按Apache-2.0单独许可。厂商SDK和运行时不随源码分发。当前仅完成模拟器及提交包,未完成RTOS真机或商店审核。
+64
查看文件
@@ -0,0 +1,64 @@
# 1.2.4 RTOS原生组件截图
以下画面来自独立原生模拟器测试工程,生产keyboard/index.ux逐字节相同,仅增加自动测试入口。四配置96个状态与96张截图通过,未使用替代绘图。这不是最终发布包的全套端到端验收;完整96张图、原始日志和源码摘要见1.2.4验收ZIP。
UI_PREVIEW.png为本版圆屏英文小写画面。白蓝和黑绿主题、圆方屏26键及光标样例:
![圆屏小写](1.2.4/round-bluewhite-en-lower.png)
![圆屏大写](1.2.4/round-bluewhite-en-upper.png)
![方屏全拼](1.2.4/rect-bluewhite-py-lower.png)
![黑绿英文](1.2.4/round-blackgreen-en-lower.png)
![圆屏文字中间光标](1.2.4/round-bluewhite-middle.png)
![方屏文字中间光标](1.2.4/rect-bluewhite-middle.png)
Android本版只有后台原生组件回归,没有前台截图。以下旧图片及旧说明均保留为明确的历史资料。
## 历史截图记录
# 截图版本说明
当前1.2.3在OWW261上运行后台离屏原生组件测试,没有采集本版前台截图。UI_PREVIEW.png及下列图片分别来自历史1.2.2和1.2.0,不作为1.2.3前台视觉验收。
# 1.2.2 实际界面
Android来自OWW261真机;RTOS来自Glyphix原生模拟器。拼图只裁掉圆屏模拟器外壳,以下保留原始帧。
![1.2.2实际界面](screenshots/android-english.png)
![1.2.2实际界面](screenshots/android-number.png)
![1.2.2实际界面](screenshots/android-symbols.png)
![1.2.2实际界面](screenshots/rtos-rect-en.png)
![1.2.2实际界面](screenshots/rtos-rect-num.png)
![1.2.2实际界面](screenshots/rtos-round-en.png)
![1.2.2实际界面](screenshots/rtos-round-num.png)
## 1.2.0 历史截图
保留旧版候选与接入示例,以下不作为1.2.2验收证据。
![1.2.0历史界面](screenshots/android-candidates-16.png)
![1.2.0历史界面](screenshots/android-fourteen.png)
![1.2.0历史界面](screenshots/android-home.png)
![1.2.0历史界面](screenshots/android-host.png)
![1.2.0历史界面](screenshots/android-phrases.png)
![1.2.0历史界面](screenshots/android-qwerty.png)
![1.2.0历史界面](screenshots/android-result.png)
![1.2.0历史界面](screenshots/rtos-rect-candidates.png)
![1.2.0历史界面](screenshots/rtos-round-14-key.png)
![1.2.0历史界面](screenshots/rtos-round-candidates.png)
![1.2.0历史界面](screenshots/rtos-round-phrase-candidates.png)
二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 132 KiB

之后

宽度:  |  高度:  |  大小: 37 KiB

文件差异过大,未显示。 加载差异
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 30 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 26 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 33 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 31 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 10 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 23 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 28 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 33 KiB

已加载 100/110 个文件。由于本次变更涉及的文件过多,其余文件未显示。 显示更多