4 次代码提交
共修改 72 个文件,包含 1724 行新增和 372 行删除

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

+91 -54
查看文件
@@ -1,60 +1,65 @@
{
"project": "蟹蟹输入 RTOS / Android手表适配改版",
"version": "1.2.0",
"version": "1.2.5",
"license": "MIT",
"android": true,
"independent_history": true,
"sensitive_findings": [],
"files": {
".gitattributes": "8e689518e5810501eb34583fa369475902805d8dd6c3ea78699469de4fa22932",
".gitignore": "cf35fe4d5c1c2ca6ae6bd343642fee3d04cd8d3799510a02ca94df73a2d4daad",
"LICENSE": "4f3547e23d7fd227a33aaa6a075185be8299403b03e534627bbb3bc3bdcf97af",
"NOTICE.md": "eb75c7be9055410bec57688b363be4761c2ee6f2ae4cfba4ab908d0b9ce68999",
"README.md": "e7d8def19bd47065776b4f9fe772585aa4a98212a1884d4f6c0efb849cfabd5c",
"android/.gitignore": "9907f78ab6ad68ac162f15ed5554c0ea9bafecf6eb25f2388ba0c11d40fab35d",
"android/LICENSE": "4f3547e23d7fd227a33aaa6a075185be8299403b03e534627bbb3bc3bdcf97af",
"android/NOTICE.md": "bba74020843ad841f979d346d13625e26a2cb1c0e9a2031f1a45e62957f132ab",
"android/README.md": "6457b0ef705b4dcaa7fc7d50e98462d48986aaef373f00ef4178abde74f12661",
"android/VALIDATION_1.2.0.md": "7ce04837f631f9e491ecfb96e8e056c4b01c4aa2f24a061d540c5280201848e6",
"android/app/build.gradle": "c6515cb16458dafba126be6c67c65ee2814da542359edf2a5fe69baf8e6fe919",
"android/app/build.gradle": "8fa1849958f77341196a67609588deba991cc3bf8c17b1933fb50a3aa1fee6aa",
"android/app/proguard-rules.pro": "41e72ccc70b31f2da13fc597d5196590ec25e0422c2766e1555f3348d64ac6ed",
"android/app/proguard-test.pro": "dc5f87f1ec90eb24823174f80d19e93d3e69919af70891529e4c54a744988aa4",
"android/app/src/androidTest/java/com/sydxky/crabinput/ImeDeviceTest.java": "1d7e8eff090e77212aa465bcc97df8698444ace92d85094cb878a3a2d286c8e6",
"android/app/src/androidTest/java/com/sydxky/crabinput/ImeDeviceTest.java": "d5a7284b6693ee116a9e5450b4c0e38df2a71812c79664ee04311d8088db33ac",
"android/app/src/main/AndroidManifest.xml": "cd096a1f1bd560114c865260bbdc50f43971089598968b065581cf07f1dbee69",
"android/app/src/main/java/com/sydxky/crabinput/MainActivity.java": "ee17c3d9f20509f382a9d2d9475d4dbddd7b57cea690bd6577a396b174400b7f",
"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.properties": "1a62f4c0a2a08044c706c378451342a11b307842b6b2ad434a86d6f970acc201",
"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": "6a47b08a172355e6e3a162b1b49617363bbc85db1c8b259d4f5a2d9279fcf484",
"android/host/build.gradle": "b39919102f52577d390697057cf809f98a599ba6e7eea19a648980a54a56462c",
"android/host/src/main/AndroidManifest.xml": "2013ca2761bc091216dca86d6b14987147e169902c4e784a2a047d473a7c6c95",
"android/host/src/main/java/com/sydxky/crabinput/host/HostActivity.java": "0b73b7b5b6286e0a00636a13ec14b81efe37898d68d8f89cd74db266aa34da83",
"android/keyboard/build.gradle": "e0c80feef0b7434ed4fa9889c5d2fbd373aa1634f9ef63229e98e7aed4df8d36",
"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": "b7fcad2114ce1945f2d26f647fc580a2c32488e83f22f869bd80f0c729eb1e6e",
"android/keyboard/src/main/AndroidManifest.xml": "343d5be1ac17cdd45d1a2f2ddc616c509290e655869125a9422a96d88aa66066",
"android/keyboard/src/main/assets/dictionary-manifest.json": "2b3d4d5564164700b46729394bfbd94349ee4a2cc949f608ec87446a7c613da3",
"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/Gradle-LICENSE.txt": "cf234c4188d773406f640bf89be680d9288f40ff96901c70d643fcea96ad46df",
"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": "9b14e96b13150b5ced15188d0eb1b6e09939f8c2c4affc8aa52147aebacf0415",
"android/keyboard/src/main/java/com/sydxky/crabinput/CandidatePager.java": "a5071ddec3cee9899b80181ef1f35c6896cdd1455df37839b600a33c29ea640e",
"android/keyboard/src/main/java/com/sydxky/crabinput/Candidate.java": "ce0ed27cb8d69d14595f6e3cbc0ac27bfac3245b8fb7f1562540054a98c81e37",
"android/keyboard/src/main/java/com/sydxky/crabinput/CandidatePager.java": "49b8c528c847ac3f66f84661914d5af749abe88a0dd1b6ece4be0ee6226339af",
"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": "014dcf83e2d1982299558204893cbd444db424ada4c2b2216585a46e7eaab47d",
"android/keyboard/src/main/java/com/sydxky/crabinput/KeyboardState.java": "d11bc8a3ce09980eee77a4258a858ff0c38292ce55900f00d29afb20f2fd7715",
"android/keyboard/src/main/java/com/sydxky/crabinput/KeyboardSurface.java": "30820264a67e5294afe188349ac78a8e61ead4c1d0e68d353585c61cb359967e",
"android/keyboard/src/main/java/com/sydxky/crabinput/Lexicon.java": "9c21767b579f6f0436175d29bb2c2244958e214d76a1b6e43ccf986dee4c7f02",
"android/keyboard/src/test/java/com/sydxky/crabinput/CandidatePagerTest.java": "7c0447f5eebea944df38179aafd789c4f2eae25ba53aff5ba9e92a2d46044c64",
"android/scripts/build-dictionary.py": "a8c29b57e42e53c5cc1e5ca6471db2ee19892db424701b1ae1cd523d2dfd0e62",
"android/keyboard/src/main/java/com/sydxky/crabinput/KeyboardController.java": "0cec282f2b2f2b1e545929671b7947d13b6004910979b39b7c20b0e31b6ee25f",
"android/keyboard/src/main/java/com/sydxky/crabinput/KeyboardState.java": "e5253607c117d18084ca1167868b3ef7ef0f28529700b70507c70af531d17291",
"android/keyboard/src/main/java/com/sydxky/crabinput/KeyboardSurface.java": "d908d33b9e3d33207c056e5528af953b1f8409d0a16fc165407ed423c801c09b",
"android/keyboard/src/main/java/com/sydxky/crabinput/Lexicon.java": "34def8a849218ca024410f390e65c7be4d8cee151a528e7e2a3651ffd36c97fa",
"android/keyboard/src/test/java/com/sydxky/crabinput/CandidatePagerTest.java": "bb08063c90db22aa9faf0712cc3d5d5f2272f4e9a514e4bd1642926ba8e0c2df",
"android/keyboard/src/test/java/com/sydxky/crabinput/CandidateRankingTest.java": "3e163c8137829de2f26fa737b4a983f0bb4544c0598f2b635e3ddc55631bcb74",
"android/LICENSE": "4f3547e23d7fd227a33aaa6a075185be8299403b03e534627bbb3bc3bdcf97af",
"android/NOTICE.md": "bba74020843ad841f979d346d13625e26a2cb1c0e9a2031f1a45e62957f132ab",
"android/README.md": "9441ad2df37bb239821d64529babf2e194e1c3bdc705a828a4105386ee70f564",
"android/scripts/build-dictionary.py": "4643f5422f2383a56d8c0653e47e3fc6b02481f065efd924958569fb48435969",
"android/scripts/capture-device.py": "34023ff4fb0ff80a8811b1222aad3b58c70b628333edb56efffa3e6adaf05ace",
"android/scripts/validate-device.py": "cd3fd2d6455eba4c560612a157678d374d43005bc8e8329fd3a206e21d087cd0",
"android/scripts/validate-device.py": "8a9252c9265807531a1e96ceb695a10a1d22fcb6d3c0b8fd18a674a1d75b4aa5",
"android/settings.gradle": "73a5f1e8c25c44b0c5f983ed822c4256dea33b14b8d53115ed3be118aeaaee1d",
"build-rtos-game.ps1": "069ebe54e3f0563ef26eca1cfa9e3552c61f50f02704b57bc513c5829ced7d8b",
"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",
"android/VALIDATION_1.2.5.md": "564221225a405d12d57b350c4acf8f05d7e034cacddb5cb534f3deda9dbac928",
"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",
@@ -62,23 +67,20 @@
"com.sydxky.rtos.imefixture/src/main/index.ux": "7a38495cbc36f6ce23dcf45ac7ddbce70c92246749e4963efddbfb2aa9ebe269",
"com.sydxky.rtos.imefixture/src/manifest.json": "3d6ba4288a0653bc8db541ca03999240bc99ece9ea1d056b7ad8cf46a6039bbf",
"com.sydxky.rtos.inputmethod/.gitignore": "0f139de1ca71ff6aa1e6f02974b5e38d219d7781d4184d524845dce12d947454",
"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/KEYBOARD_14.md": "27ae1fa73dd8fa8fce70743fa1e418b4a4ff71ba610e735f009344896adfa754",
"com.sydxky.rtos.inputmethod/LICENSE": "4f3547e23d7fd227a33aaa6a075185be8299403b03e534627bbb3bc3bdcf97af",
"com.sydxky.rtos.inputmethod/NOTICE.md": "eb75c7be9055410bec57688b363be4761c2ee6f2ae4cfba4ab908d0b9ce68999",
"com.sydxky.rtos.inputmethod/PRIVACY.md": "c3062d8434db2fad3b6824cf2c41561a0bf6ae05e62f05d7c6316efd64d74af9",
"com.sydxky.rtos.inputmethod/README.md": "8ffdcdd0bcf669908da59615f98208cca2807f6c6f8b28499d332c5130aa477b",
"com.sydxky.rtos.inputmethod/VALIDATION_1.1.0.md": "3b7346853bf6679c4efeda0568caece9ecb28dfde6347c96a67dc245a1e08ab9",
"com.sydxky.rtos.inputmethod/VALIDATION_1.2.0.md": "db7cf247579c3f6be73eaecf75fc9553214df8120ee25dc7d51c402a34ea89d6",
"com.sydxky.rtos.inputmethod/build.ps1": "32168e753699f48274fe2492e53b7285e4b62c15dc928f3aeb366a11150ebf5a",
"com.sydxky.rtos.inputmethod/glyphix.config.js": "43beae1f2a61eed010149ae6ad77223ba084117ea5aee3cb3fcd48bfc0a4a32b",
"com.sydxky.rtos.inputmethod/PRIVACY.md": "19049b0bb30add6fa5071ec16e9494f835e04ee1dad83108bfec34c71e16d679",
"com.sydxky.rtos.inputmethod/README.md": "5af5ae16e011b7e0c5c5c9ebcd4480751a0dcff3070910e9813eb3344114e4fa",
"com.sydxky.rtos.inputmethod/scripts/build-icons.py": "51f859a9517e7dd2e5e4bf9f688ce41dc79d4bc491fc66066dfe2db4aae4d704",
"com.sydxky.rtos.inputmethod/scripts/convert-dictionary.py": "83eea8c7adae0254eb69170dec905c285c09c43ffcf10e8e553beb108acf43b3",
"com.sydxky.rtos.inputmethod/scripts/install.mjs": "46c7d95b25c312734c0ca8387f4a782becd4723f21d01492d074da22ca05432d",
"com.sydxky.rtos.inputmethod/scripts/convert-dictionary.py": "9dfd89a2691b43a0988f5a8180030e3a4258b86b97d444d53d6539929cb2fd22",
"com.sydxky.rtos.inputmethod/scripts/install.mjs": "6338d47aac16c95ebd4a4cf2a821bcb926689f3fbbe39b59e2e6efd50e9aef3f",
"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/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-left-s-line.png": "68d8920081fb883f4c59af47549d6b41a9b0a3748383b814e4480bfab0e711ed",
"com.sydxky.rtos.inputmethod/src/assets/ime-icons/arrow-right-s-line.png": "c6bffa0a51254c60c92e276e4d21ae8a1595d82c77d9d6a1064afbf534ea3562",
@@ -87,20 +89,25 @@
"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/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/common/ime-engine.js": "80e70d69561e424f4e42d5b3f33880bfa87df4d04119eb4f49b46b2dde4dce3d",
"com.sydxky.rtos.inputmethod/src/common/ime-lifecycle.js": "93525dffabcca0620acc36defd077ab6677b1032ef67faf695f3fbf3116b8a64",
"com.sydxky.rtos.inputmethod/src/keyboard/index.ux": "d7b4849824cb31882bce496aaecbbe26b82b772820c54327a263f6758ec5b217",
"com.sydxky.rtos.inputmethod/src/common/association-seeds.json": "181aa6f3bd77cf2f1002e405cbbfd5240f3c75a560f1a79c42cca63b70f05b58",
"com.sydxky.rtos.inputmethod/src/common/ime-engine.js": "aec3bc585e887fd84bd544af1090e34aa4a76714fbfaaf39dba8397d783ebe0b",
"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": "ab9f4e95165da6968f848cc71b42e2c7dfd4fd628b1d3a38a57b34df70666c82",
"com.sydxky.rtos.inputmethod/src/keyboard/index.ux": "27a14680bff055c4625d7858879df7cde804b6aa399c81a07ce1079cdc60140d",
"com.sydxky.rtos.inputmethod/src/main/index.ux": "f763f0dc80b9fd46e00830d6ec0de8683063f98c1edee94ee522c2605b695fca",
"com.sydxky.rtos.inputmethod/src/manifest.json": "4520f786d4159d1cf8aedd0bad73a66bf3494729ca1de69ea66e2c9fad3fba64",
"com.sydxky.rtos.inputmethod/tests/client.test.mjs": "01bb6191f26d0f50496f1e032a39bf3bcce567bbbc33727491c5246dc6bf9900",
"com.sydxky.rtos.inputmethod/tests/engine.test.mjs": "86408d7629f189a11327ee801e75e472414d3a7979db60c12772debcf447ce23",
"com.sydxky.rtos.inputmethod/tests/layout.py": "b632411cd593d369180abaeedbf036002e986e3e5e2c2f15b4e5ff157588aa78",
"com.sydxky.rtos.inputmethod/tests/native.py": "307d14506af92388c044e30e8095ac4640b710d988ec10d31f3eecd1c9a9a093",
"com.sydxky.rtos.inputmethod/tests/visual.py": "25bb14687278b6961e1e4957dafdb28b95d1056fdbe58e6506bca853cd5f1fec",
"com.sydxky.rtos.inputmethod/third_party/UPSTREAM.md": "e8311679981a318fef7142f7a2ecb2b1a7860f5fee32282729cab6af2a41d640",
"com.sydxky.rtos.inputmethod/src/manifest.json": "549525d87a38520499f37dc8c7a05e8873bd8e57fe2b81ef7013e57676734b50",
"com.sydxky.rtos.inputmethod/tests/client.test.mjs": "a99efb94856d4a9eaa9b9c7ccbae288fbc15d3363ac0a0e71028a1b53dbd78bd",
"com.sydxky.rtos.inputmethod/tests/console-native.py": "ba3cc062d0206da9d45984a6d9dd07e519a9967f64ac49ad9750b4efc8c7aac9",
"com.sydxky.rtos.inputmethod/tests/engine.test.mjs": "984a8cea899b8e0762773d48d56566729a9e720f402fa7b946645d0afa429e1a",
"com.sydxky.rtos.inputmethod/tests/layout.py": "9b1fc238cae2f39e5385611cb3d5d636e40941cd4566c51d1407cfb72d6dbcff",
"com.sydxky.rtos.inputmethod/tests/native.py": "07bb05ad97fe2ca06f7f4a55e57b914b025dd70c0a6ea0249c1195e05aed5fe5",
"com.sydxky.rtos.inputmethod/tests/performance.mjs": "12eb80c8303e4a9d7355f04abdecd845f152ea68215ad81e5475b44c0ae09dbe",
"com.sydxky.rtos.inputmethod/tests/sync.test.mjs": "230fd14f91425085dc5d75655844ddfee48ce429d328ef2ff3c83035ca60cc0a",
"com.sydxky.rtos.inputmethod/tests/visual.py": "cdd48d7cb4851fbcb6b96af5098405f4d02e93726cf02320faa461592b948477",
"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/pinyinDictV5_20260416/key0.dat": "795a5316089b67a7bffabf87080bd61f3720f9e0fd2f2866a092901f99b794e8",
"com.sydxky.rtos.inputmethod/third_party/crabKeyboard/pinyinDictV5_20260416/key1.dat": "a4bcae2b6eb30084d3cf2ac64e5d9ca71eb94e8ac1f7c8fc8b38a5d478982be2",
@@ -143,10 +150,9 @@
"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/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/remix-icon/LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/README.md": "6a7bee86ad1942d01be10b556dd3036e43a59d708fc94fd2f5f3e3fcfcf84b0b",
"com.sydxky.rtos.inputmethod/third_party/jieba/LICENSE": "18ba0984839f85853b29fadaf992f7dba8fd0ca0fbeae34de2b8735222dc7a37",
"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-left-s-line.iconfont.svg": "ccf02486d37383bbdc3319d9b348c5daef43693526d41aff6ad409d04e0664ef",
@@ -164,20 +170,51 @@
"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.upstream.svg": "e39f28cbf418269405f517126a64cbf4b3332b3295cc8a3af670f77344a669c4",
"docs/SCREENSHOTS.md": "c38fcb14f6140a392b2a044b4e98cc625798a4579cce830075fd7f83aaf882ca",
"docs/UI_PREVIEW.png": "ef7767cd8c38051d332f045ff879d343ebc9966a2a94d2468b8d19604bff7ce1",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
"com.sydxky.rtos.inputmethod/third_party/remix-icon/README.md": "6a7bee86ad1942d01be10b556dd3036e43a59d708fc94fd2f5f3e3fcfcf84b0b",
"com.sydxky.rtos.inputmethod/third_party/UPSTREAM.md": "e8311679981a318fef7142f7a2ecb2b1a7860f5fee32282729cab6af2a41d640",
"com.sydxky.rtos.inputmethod/VALIDATION_1.1.0.md": "3b7346853bf6679c4efeda0568caece9ecb28dfde6347c96a67dc245a1e08ab9",
"com.sydxky.rtos.inputmethod/VALIDATION_1.2.0.md": "db7cf247579c3f6be73eaecf75fc9553214df8120ee25dc7d51c402a34ea89d6",
"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",
"com.sydxky.rtos.inputmethod/VALIDATION_1.2.5.md": "8cfe75e685aa7262c948bd09e57dd00a4a5d9b1e309cca619d70279646662169",
"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/1.2.5/PERFORMANCE.json": "393301811ca2fee19d12472f0735ba7f2068bb7ae7f31d6ab977415d59ada634",
"docs/1.2.5/rect-bluewhite-middle.png": "56ace9bbba7374c7347d84ad8beb860a1465a2860f9203127a1cb0fc3ab90d8c",
"docs/1.2.5/rect-bluewhite-py-lower.png": "ef92c657bf08e8eeffe8f6cb6c5291cbff6951db697faff6396001f96c07ae91",
"docs/1.2.5/round-blackgreen-en-lower.png": "54829d3703b378328874aa80e99d94f92d3e029cce8752198506d85335587040",
"docs/1.2.5/round-bluewhite-en-lower.png": "649c488bceab217cc985794affb9ac36a672df775d3ffe574307ac52da183b39",
"docs/1.2.5/round-bluewhite-en-upper.png": "0613d268f1d5992788be90670aebaddf0202a040508ae5475a03390922a2d19e",
"docs/1.2.5/round-bluewhite-middle.png": "c4ead177abb3c7dc330f0b228640a7a19bf17dc8973c5c4d820322fc6d56f948",
"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",
"tests/emulator-validate.py": "2a549e3c7bc177b942826425e548f68db39a32d8a55ade080f0d8ab3e9c00060",
".gitattributes": "8e689518e5810501eb34583fa369475902805d8dd6c3ea78699469de4fa22932"
"docs/SCREENSHOTS.md": "d5ab2209969317d421e2664cfb2afcd96012d5b2b9d3ca54695087cc6b0d5f53",
"docs/UI_PREVIEW.png": "649c488bceab217cc985794affb9ac36a672df775d3ffe574307ac52da183b39",
"LICENSE": "4f3547e23d7fd227a33aaa6a075185be8299403b03e534627bbb3bc3bdcf97af",
"NOTICE.md": "eb75c7be9055410bec57688b363be4761c2ee6f2ae4cfba4ab908d0b9ce68999",
"README.md": "f05fa72c8047e6fae5ebee56d840963f04783568f719bdb6081660af9401d7f4",
"tests/emulator-validate.py": "2a549e3c7bc177b942826425e548f68db39a32d8a55ade080f0d8ab3e9c00060"
}
}
+31 -7
查看文件
@@ -4,9 +4,27 @@
**MIT 开源,允许商用、修改、再分发和闭源集成。** 使用时请保留 [LICENSE](LICENSE) 和 [第三方许可](NOTICE.md)。厂商 SDK、运行时与设备固件不包含在本项目授权中。
![1.2.0 实际模拟器与手表界面](docs/UI_PREVIEW.png)
![1.2.5 RTOS原生测试工程界面,验证范围见截图说明](docs/UI_PREVIEW.png)
## 1.2.0 带来了什么
## 1.2.5 减少内存、加速查词与等大26键
- RTOS运行索引91,357→2,158 B,缓存3分片/48,000字符;去掉整份词库复制,隐藏释放缓存,过期查询停止后续工作。
- 候选按需分页,首页只更新3项,展开才创建整数页计划,只生成当前页;RTOS反应式字段40→29。Android取消过期SQLite、后台规划候选页,文字变化复用键盘布局。
- 26字母统一宽高并放大,标准10/9/7三排,方屏利用全宽,圆屏同步调整留白、候选与底栏。保留光标贴合和大写/小写切换。
同一词库、24种全拼/九宫/14键编码,5轮共120次查询:总耗时 227.12 → 194.87 ms,P95 6.70 → 5.01 ms,中位数 1.01 → 1.07 ms;GC后保留堆 1,149,800 → 805,848 B。总耗时减少14.2%,P95减少25.2%,保留堆减少29.9%。中位数略增,不能称所有输入都提速。
这是Windows Node/V8的对比,数值随主机负载变化;不是Glyphix MCU/真实手表的内存、帧率或触控延迟。原始报告及执行脚本随源码/验收ZIP提供。
RTOS **59项引擎、49项UX、同步检查及四配置构建通过**,独立原生组件四配置**96状态/96截图通过**,26键等大、模式和底部控件边界合格,各配置实际输入qQq。24种编码的完整候选及补全与1.2.4一致,12,000候选分页顺序完整。
Android **8项Java、7项OWW261后台离屏组件回归通过**:原生绘制、24次文字更新只创建一次布局、补全/续词、密码隔离、大小写点击、圆方/192px尺寸等大键和边界、查询取消/恢复。16个运行时class与发布AAR相同;正式签名APK/AAR、API27和zipalign通过。
这是组件回归,不替代最终APK的前台整包验收。最终Android APK未安装到共享手表,原9项前台交互及RTOS原84项整包交互未重跑。RTOS截图来自明确标记的原生测试工程,Android截图为历史资料;RTOS真机、长期性能/功耗及商店审核未验收。
[本版RTOS验收](com.sydxky.rtos.inputmethod/VALIDATION_1.2.5.md) · [本版Android验收](android/VALIDATION_1.2.5.md)
## 1.2.0 已有功能
- 完整候选采用 **4×4 网格,单字 16 项一页**;词组占用更多列,长词增加行高,显示完整文字;展开页不显示拼音。
- 标准 **QWERTY 26 键**,排列为 `qwertyuiop / asdfghjkl / zxcvbnm`;同时提供九宫和 14 键双字母模式。
@@ -19,8 +37,8 @@
| 平台 | 接入与产物 | 已完成的验证 |
| --- | --- | --- |
| Glyphix RTOS | 原生 `.ux` 页面;圆屏 466×466、方屏 390×450 两屏 QuickApp 完整 ZIP | 普通/QuickApp 四配置构建;84 项原生操作、84 项布局、48 个视觉状态 |
| Android 8.1 / API 27+ | 原生 AAR;演示 APK;独立 `host/` 示例 | OWW261 Android 11 真机 8 项交互测试,系统默认输入法保持不变 |
| Glyphix RTOS | 原生 `.ux` 页面;圆屏 466×466、方屏 390×450 两屏 QuickApp 完整 ZIP | 普通/QuickApp四配置构建;59项引擎和49项UX;独立原生组件四配置96状态/截图通过 |
| Android 8.1 / API 27+ | 原生 AAR;演示 APK;独立 `host/` 示例 | OWW261后台原生组件7项通过;24帧非空绘制、布局复用、等大键及查询取消;前台交互未重跑 |
Android 版本作为**应用内公开组件**,由每个应用自行接入;组件不注册系统输入法。RTOS 页面也需宿主接入,系统级注册依赖厂商支持。
@@ -28,7 +46,7 @@ Android 版本作为**应用内公开组件**,由每个应用自行接入;
## 下载、源码与反馈
- [v1.2.0 发行包与更新说明](https://git2.sydxky.cn/sydxky/crab-input-rtos/releases/tag/v1.2.0):AAR、演示 APK、两屏 RTOS QuickApp、验收截图及 SHA-256。
- [v1.2.5 发行包与更新说明](https://git2.sydxky.cn/sydxky/crab-input-rtos/releases/tag/v1.2.5):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)
@@ -69,7 +87,7 @@ CrabKeyboard.Binding keyboard = CrabKeyboard.attach(this, editText);
& .\com.sydxky.rtos.inputmethod\build.ps1
```
脚本生成词库分片,执行 44 项引擎、32 项 UX 断言,并构建普通/QuickApp 四配置。每屏 QuickApp 都须保留 `bundle.pkg`、`icon.png`、`manifest.json` 三个文件。
脚本生成词库分片,执行 59 项引擎、49 项 UX 断言,并构建普通/QuickApp 四配置。每屏 QuickApp 都须保留 `bundle.pkg`、`icon.png`、`manifest.json` 三个文件。
复现原生验收时先构建最小参考页并启动一次圆屏模拟器,获得厂商 launcher/global 资源后退出;不要同时占用 Inspector 14201:
@@ -82,4 +100,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
```
视觉脚本依赖 Pillow。RTOS 运行时内存配置保持 8192 KiB。本版本完成模拟器、Android 真机组件及提交包验收;RTOS 真机、长期性能/续航和商店审核尚未验收。
本版光标、等大26键及完整底部按钮回归可在独立原生模拟器中执行,不启用Inspector:
```powershell
python .\com.sydxky.rtos.inputmethod\tests\console-native.py --work work/caret --output work/evidence/caret
```
视觉脚本依赖 Pillow。RTOS 运行时内存配置保持 8192 KiB。本版本已完成上述算法、组件回归与构建检查;未完成的整包前台交互、RTOS真机、长期性能/续航和商店审核范围见本版验收记录。
+11 -3
查看文件
@@ -1,17 +1,25 @@
# 蟹蟹输入 Android应用内键盘组件
基于CrabKeyboard MIT词库,由herry重写的Android手表原生键盘组件,1.2.0 / 10200。提供可集成的 **AAR**、演示APK和独立接入示例;适用Android 8.1 / API 27及以上。
基于CrabKeyboard MIT词库,由herry重写的Android手表原生键盘组件,1.2.5 / 10205。提供可集成的 **AAR**、演示APK和独立接入示例;适用Android 8.1 / API 27及以上。
应用接入后,点击自己的输入框自动展开内置键盘。**不注册系统输入法、不要求用户切换输入法、不改变系统默认输入法。** 圆屏按每行所在高度计算安全弦宽;4×4完整候选,单字16个一页,词组扩宽或增加行高,候选不显示拼音。九宫、标准QWERTY全拼/英文、14键、数字、三页符号、Unicode删除、按住连删及光标编辑。
完全离线,无网络或外部存储权限;组件不持久化输入内容,不启用学习。密码输入默认英文并遮蔽预览。页面隐藏/销毁时清除组件会话与候选,宿主输入框中的内容仍由宿主决定如何处理。异步查询按会话序号隔离,旧结果不能回填新的输入框。
1.2.5 取消过期SQLite查询,后台只创建整数分页计划,当前页才生成候选单元;文字更新复用键盘布局。26字母等宽等高并扩大触控区,圆方屏安全边界通过,保留大小写切换。8项Java和7项后台原生组件测试通过;验证范围见[本版验收](./VALIDATION_1.2.5.md)。
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.0.aar`放入宿主`app/libs/`:
将发行版`crab-keyboard-1.2.5.aar`放入宿主`app/libs/`:
```groovy
dependencies { implementation files('libs/crab-keyboard-1.2.0.aar') }
dependencies { implementation files('libs/crab-keyboard-1.2.5.aar') }
```
```java
+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截图仅作历史资料。另一款方表、长期性能/功耗与商店审核未验收。
+13
查看文件
@@ -0,0 +1,13 @@
# Android 1.2.5 内存、刷新及等大26键验收
2026-11-14,版本1.2.5/10205,最低Android8.1/API27。
候选分页只保留整数页起点,后台线程计算页计划,当前页才生成单元对象;不再每次查词创建所有候选页。取消旧Future同时通过CancellationSignal取消SQLite读取。预编译中文上下文正则。键盘结构未变时只更新文字及候选事件,不重建Placements或逐键重设样式;仍按帧合并刷新并复用控件,避免输入闪动。
26字母维持标准10/9/7三排,以三排安全弦宽计算统一单元宽度,方屏充分使用宽度,缩小间距并增加高度。所有字母等宽等高(像素取整最多1px),保留明确的大写/小写按钮和qQq实际输入。圆屏同步保留底部操作区。
8项Java测试通过,包含12,000候选的按需分页、全量顺序及完整性。Release APK/AAR、宿主、后台组件测试APK构建通过,SQLite无损回读104,837组索引/163,238候选。lint 0错误:演示1项备份声明警告,组件2项测试依赖提示。最终APK为1.2.5/10205,API27、zipalign、原生产证书1名签名者和v2签名通过。APK SHA-256:b61729cb9698be794c2eb406d3eb3c9c770ad727e67d6f2fdf722aa9f220eea2。
OWW261 Android11/API30上7项后台离屏原生组件测试通过:24帧实际measure/layout/draw文字非空、控件不重建;相同尺寸连续24次文字/候选更新只创建一次布局;补全/续词只提交一次;密码上下文隔离;实际点击大小写切换输入qQq;模拟圆形466/192与方形466、390×450、192尺寸的26键等大、全部控件矩形及圆屏按钮四角边界;取消词库查询及后续恢复。
测试未打开Activity、切换前台或修改系统默认输入法,使用debug组件,16个运行时class与发布AAR逐字节相同。最终APK没有安装到共享手表,原9项前台交互未重跑;这些组件测试不代替发布APK前台端到端视觉验收。没有新的Android前台截图。没有Android进程内存、真实打字延迟、长期功耗测量;另一款实体方表和商店审核未验收。
+2 -2
查看文件
@@ -12,8 +12,8 @@ android {
applicationId 'com.sydxky.crabinput'
minSdk 27
targetSdk 35
versionCode 10200
versionName '1.2.0'
versionCode 10205
versionName '1.2.5'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
signingConfigs {
@@ -113,4 +113,24 @@ public class ImeDeviceTest {
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("收起键盘");
}
}
@@ -46,7 +46,7 @@ public final class MainActivity extends Activity {
// 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.setSingleLine(true);
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));
}
+1 -1
查看文件
@@ -2,7 +2,7 @@ plugins { id 'com.android.application' }
android {
namespace 'com.sydxky.crabinput.host'
compileSdk 37
defaultConfig { applicationId 'com.sydxky.crabinput.host'; minSdk 27; targetSdk 35; versionCode 10200; versionName '1.2.0' }
defaultConfig { applicationId 'com.sydxky.crabinput.host'; minSdk 27; targetSdk 35; versionCode 10205; versionName '1.2.5' }
compileOptions { sourceCompatibility JavaVersion.VERSION_11; targetCompatibility JavaVersion.VERSION_11 }
}
dependencies { implementation project(':keyboard') }
@@ -13,7 +13,7 @@ public final class HostActivity extends Activity {
@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("点击输入");layout.addView(editor,new LinearLayout.LayoutParams(-1,-2));
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();}
+6 -2
查看文件
@@ -2,8 +2,12 @@ plugins { id 'com.android.library' }
android {
namespace 'com.sydxky.crabinput.keyboard'
compileSdk 37
defaultConfig { minSdk 27; consumerProguardFiles 'consumer-rules.pro' }
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' }
dependencies {
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
}
@@ -0,0 +1,142 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.os.SystemClock;
import android.os.CancellationSignal;
import android.os.OperationCanceledException;
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 Context context(boolean round){Context base=context();Configuration configuration=new Configuration(base.getResources().getConfiguration());configuration.screenLayout=(configuration.screenLayout&~Configuration.SCREENLAYOUT_ROUND_MASK)|(round?Configuration.SCREENLAYOUT_ROUND_YES:Configuration.SCREENLAYOUT_ROUND_NO);return new ContextThemeWrapper(base.createConfigurationContext(configuration),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();}
});
}
@Test public void enlargedAlphabetKeysHaveEqualSizeOnRoundAndRectangularScreens(){
ui(()->{
for(boolean round:new boolean[]{false,true})for(int[] size:round?new int[][]{{466,466},{192,192}}:new int[][]{{466,466},{390,450},{192,192}})for(String mode:new String[]{"py","en"}){
Context themed=context(round);KeyboardState state=new KeyboardState();state.mode=mode;
KeyboardSurface surface=new KeyboardSurface(themed,state,new KeyboardSurface.Actions(){public void action(String v){}public void choose(Candidate v){}public void repeatDelete(boolean v){}});
int width=size[0],height=size[1];surface.measure(View.MeasureSpec.makeMeasureSpec(width,View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(height,View.MeasureSpec.EXACTLY));surface.layout(0,0,width,height);
int minW=Integer.MAX_VALUE,maxW=0,minH=Integer.MAX_VALUE,maxH=0;
for(char letter:"qwertyuiopasdfghjklzxcvbnm".toCharArray()){
View key=find(surface,String.valueOf(letter));assertNotNull(key);minW=Math.min(minW,key.getWidth());maxW=Math.max(maxW,key.getWidth());minH=Math.min(minH,key.getHeight());maxH=Math.max(maxH,key.getHeight());
}
assertTrue("All 26 letters have the same width within pixel rounding",maxW-minW<=1);assertTrue("All 26 letters have the same height within pixel rounding",maxH-minH<=1);assertTrue(minW>width*.07);assertTrue(minH>height*.13);
for(int i=0;i<surface.getChildCount();i++){
View child=surface.getChildAt(i);assertTrue("No child overflows",child.getWidth()>0&&child.getHeight()>0&&child.getLeft()>=0&&child.getTop()>=0&&child.getRight()<=width&&child.getBottom()<=height);
if(round&&child instanceof android.widget.Button)for(int x:new int[]{child.getLeft(),child.getRight()})for(int y:new int[]{child.getTop(),child.getBottom()})assertTrue("Round buttons stay inside safe screen",Math.hypot(x-width/2.0,y-height/2.0)<=width/2.0-1);
}
}
});
}
@Test public void sameSizeEditsDoNotRebuildTheKeyboard(){
ui(()->{
KeyboardState state=new KeyboardState();state.mode="py";state.candidates=Arrays.asList(new Candidate("你好",5));
KeyboardSurface surface=new KeyboardSurface(context(),state,new KeyboardSurface.Actions(){public void action(String v){}public void choose(Candidate v){}public void repeatDelete(boolean v){}});
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));
surface.forceLayout();surface.measure(View.MeasureSpec.makeMeasureSpec(466,View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(466,View.MeasureSpec.EXACTLY));surface.layout(0,0,466,466);
assertEquals("Text-only updates reuse placements and key layout",1,surface.layoutBuilds);assertEquals("输入"+frame,((TextView)find(surface,"编辑光标")).getText().toString());assertNotNull(find(surface,frame%2==0?"你好":"您好"));
}
});
}
@Test public void cancelledDictionaryQueryDoesNotBreakFollowingInput()throws Exception{
try(Lexicon lexicon=new Lexicon(context())){
CancellationSignal signal=new CancellationSignal();signal.cancel();
try{lexicon.lookup("nihao","py","",signal);fail("Cancelled lookup must stop");}catch(OperationCanceledException expected){}
try{lexicon.predict("你好",signal);fail("Cancelled continuation must stop");}catch(OperationCanceledException expected){}
assertEquals("你好",lexicon.lookup("niha","py").get(0).word);assertEquals("世界",lexicon.predict("你好").get(0).word);
}
}
}
@@ -1,9 +1,12 @@
{
"schema_version": 2,
"association_prefixes": 28977,
"association_candidates": 52443,
"keys": 104837,
"candidates": 163238,
"longest_word": 4,
"sha256": "fa01b1ab0264d8b271a32338b93a64b8670a71d57264c8586c89ae3cf3ddd8a2",
"bytes": 12644352,
"sha256": "6575841fb5d6232d1624bd91c19e8f39b030eca4a4e09a6bcff21e12b12b27d4",
"bytes": 13688832,
"round_trip": true,
"source_files": {
"key0.dat": "795a5316089b67a7bffabf87080bd61f3720f9e0fd2f2866a092901f99b794e8",
@@ -3,5 +3,10 @@ package com.sydxky.crabinput;
public final class Candidate {
public final String word;
public final int consume;
public Candidate(String word, int consume) { this.word=word; this.consume=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;
}
}
@@ -2,6 +2,8 @@ package com.sydxky.crabinput;
import java.util.ArrayList;
import java.util.List;
import java.util.AbstractList;
import java.util.RandomAccess;
/** 4 columns x 4 row units; long phrases get full width and extra row units. */
public final class CandidatePager {
@@ -16,7 +18,7 @@ public final class CandidatePager {
public int slots;
Row(int units) { this.units=units; }
}
public static List<List<Row>> pages(List<Candidate> candidates) {
private static List<List<Row>> materialize(List<Candidate> candidates) {
List<List<Row>> pages=new ArrayList<>();List<Row> page=new ArrayList<>();
Row row=null;int used=0;
for(Candidate candidate:candidates) {
@@ -33,5 +35,27 @@ public final class CandidatePager {
if(!page.isEmpty())pages.add(page);
return pages;
}
private static final class Pages extends AbstractList<List<Row>> implements RandomAccess {
private final List<Candidate> candidates;
private final List<Integer> starts=new ArrayList<>();
Pages(List<Candidate> candidates){
this.candidates=candidates;int used=0,slots=0,rowUnits=0;
for(int i=0;i<candidates.size();i++){
String word=candidates.get(i).word;int length=word.codePointCount(0,word.length());
int span=length==1?1:length<=4?2:4,units=span==4?Math.max(1,(length+11)/12):1;
if(slots>0&&(slots+span>4||rowUnits!=units)){used+=rowUnits;slots=0;}
if(starts.isEmpty()||used+units>4){starts.add(i);used=0;slots=0;}
if(slots==0)rowUnits=units;
slots+=span;if(slots==4){used+=rowUnits;slots=0;}
}
}
@Override public int size(){return starts.size();}
@Override public List<Row> get(int page){
if(page<0||page>=size())throw new IndexOutOfBoundsException();
int end=page+1<size()?starts.get(page+1):candidates.size();
return materialize(candidates.subList(starts.get(page),end)).get(0);
}
}
public static List<List<Row>> pages(List<Candidate> candidates){return new Pages(candidates);}
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(){}
}
@@ -12,6 +12,7 @@ import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import android.os.CancellationSignal;
/** One transient in-app editing session. It never selects a system IME. */
final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable {
@@ -25,9 +26,10 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
private final Runnable dismiss;
private final Context context;
private Future<?> pending;
private CancellationSignal cancellation;
private int revision;
private boolean active=true,repeat,closed;
private final Runnable lookup=()->query(revision,state.composition,state.mode);
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){
@@ -37,33 +39,42 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
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 invalidateLookup(){revision++;main.removeCallbacks(lookup);if(cancellation!=null){cancellation.cancel();cancellation=null;}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;state.candidates=new ArrayList<>();state.pages=new ArrayList<>();
if(!state.composition.isEmpty()&&active){state.busy=true;main.postDelayed(lookup,40);}refresh();
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){
private void query(int owner,String composition,String mode,String context){
CancellationSignal signal=new CancellationSignal();cancellation=signal;
pending=worker.submit(()->{
try {List<Candidate> result=lexicon.lookup(composition,mode);main.post(()->{if(!active||revision!=owner)return;state.candidates=result;state.pages=CandidatePager.pages(result);state.busy=false;state.message=result.isEmpty()?"暂无匹配,请退格修改":"离线输入";refresh();});}
catch(Exception ignored){main.post(()->{if(active&&revision==owner){state.busy=false;state.message="词库暂不可用,可切换英文";refresh();}});}
try {List<Candidate> result=composition.isEmpty()?lexicon.predict(context,signal):lexicon.lookup(composition,mode,context,signal);List<List<CandidatePager.Row>> pages=CandidatePager.pages(result);main.post(()->{if(!active||revision!=owner)return;state.candidates=result;state.pages=pages;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)input.commitText(text,1);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.candidates.contains(item))return;
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();commit(item.word);state.composition=remaining;state.expanded=false;schedule();
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);refresh();
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){
@@ -88,8 +99,8 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
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));}break;
case "raw":if(state.mode.equals("py")&&!state.composition.isEmpty()){String text=state.composition;state.composition="";commit(text);schedule();}break;
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;}
@@ -5,7 +5,7 @@ import java.util.List;
final class KeyboardState {
String mode="t9",composition="",preview="",message="离线输入";
boolean expanded,tools,upper,secret,busy;
boolean expanded,tools,upper,secret,busy,prediction;
int page,symbolPage;
List<Candidate> candidates=new ArrayList<>();
List<List<CandidatePager.Row>> pages=new ArrayList<>();
@@ -16,6 +16,10 @@ 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.
@@ -26,7 +30,15 @@ final class KeyboardSurface extends ViewGroup {
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 String builtKey="";
private List<Candidate> builtCandidates;
int layoutBuilds;
private final Runnable render=this::renderFrame;
private void renderFrame(){refreshPosted=false;if(builtWidth>0){if(!state.expanded&&!state.tools&&layoutKey().equals(builtKey))updateContent();else{build(builtWidth,builtHeight);requestLayout();}}else requestLayout();invalidate();}
private float sx=1,sy=1;
private static final class Placement {
final View view;final Rect bounds;
@@ -41,7 +53,7 @@ final class KeyboardSurface extends ViewGroup {
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(builtWidth>0)build(builtWidth,builtHeight);requestLayout();invalidate(); }
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));
}
@@ -53,25 +65,29 @@ final class KeyboardSurface extends ViewGroup {
return Math.max(8,(builtWidth/2f-half)/sx+2);
}
private void place(View view,float x,float y,float width,float height) {
addView(view);placements.add(new Placement(view,bounds(x,y,width,height)));
if(view.getParent()==null)addView(view);placements.add(new Placement(view,bounds(x,y,width,height)));
}
private TextView label(String text,float font,int color) {
TextView view=new TextView(getContext());view.setText(text);view.setTextColor(color);view.setGravity(Gravity.CENTER);
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 text,String description,float font,boolean primary,Runnable action) {
Button button=new Button(getContext());button.setText(text);button.setContentDescription(description);button.setAllCaps(false);
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);
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;
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(text,name,font,primary,()->actions.action(value));
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);
@@ -83,34 +99,55 @@ final class KeyboardSurface extends ViewGroup {
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 String layoutKey(){return state.mode+":"+state.upper+":"+state.symbolPage+":"+state.tools+":"+state.expanded+":"+state.page+":"+Math.min(3,state.candidates.size());}
private void text(String key,String value){TextView view=(TextView)children.get(key);if(view!=null&&!view.getText().toString().equals(value))view.setText(value);}
private String compositionText(){
if(state.composition.isEmpty())return modeName();
if(!state.mode.equals("14"))return state.composition;
StringBuilder result=new StringBuilder();for(char ch:state.composition.substring(Math.max(0,state.composition.length()-6)).toCharArray())result.append(Lexicon.GROUP14["0123456789abcd".indexOf(ch)].toUpperCase(Locale.ROOT)).append(' ');return result.toString();
}
private void updateContent(){
text("preview",state.secret?"私密输入":state.preview.isEmpty()?"开始输入…":state.preview);text("composition",compositionText());
text("candidate-status",state.busy?"正在查词…":state.message);
for(int i=0;i<Math.min(3,state.candidates.size());i++){
Candidate candidate=state.candidates.get(i);Button button=(Button)children.get("candidate:"+i);if(button==null)continue;
if(!button.getText().toString().equals(candidate.word))button.setText(candidate.word);
button.setContentDescription(candidate.word);button.setEnabled(!state.busy);
if(builtCandidates!=state.candidates)button.setOnClickListener(v->actions.choose(candidate));
}
builtCandidates=state.candidates;
}
private void build(int width,int height) {
builtWidth=width;builtHeight=height;sx=width/410f;sy=height/410f;removeAllViews();placements.clear();
float top=round?28:7;
builtWidth=width;builtHeight=height;builtKey=layoutKey();builtCandidates=state.candidates;layoutBuilds++;sx=width/410f;sy=height/410f;placements.clear();used.clear();
try{
float top=round?20:7;
float headerInset=inset(top,23);
action("⌄","hide",headerInset,top,32,23,23,false);
String title=state.expanded?"完整候选":state.tools?"编辑工具":"蟹蟹输入";
place(label(title,14,MUTED),headerInset+34,top,410-2*headerInset-68,23);
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,21,INK);previewView.setMaxLines(1);previewView.setEllipsize(android.text.TextUtils.TruncateAt.START);
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.isEmpty()?modeName():composition,15,BLUE);compose.setMaxLines(1);
TextView compose=label("composition",compositionText(),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(item.word,item.word,22,false,()->actions.choose(item));b.setMaxLines(1);b.setAutoSizeTextTypeUniformWithConfiguration(10,22,1,TypedValue.COMPLEX_UNIT_PX);place(b,left+i*cell+2,candidateY,cell-4,32);}
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(state.busy?"正在查词…":state.message,12,MUTED),left,candidateY,available,32);
else place(label("candidate-status",state.busy?"正在查词…":state.message,12,MUTED),left,candidateY,available,32);
}
float keyY=candidateY+38;
// 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;
@@ -121,24 +158,38 @@ final class KeyboardSurface extends ViewGroup {
}
float keyHeight=captions.length==4?36:49;
// Hide an unused suggestion strip in direct entry modes to enlarge the keys.
if(!isChinese()){keyY-=27;keyHeight=captions.length==4?43:58;}
for(int i=0;i<captions.length;i++)row(captions[i],values[i],keyY+i*(keyHeight+4),keyHeight,captions[i].length>=7?21:24);
if(!isChinese()){keyHeight=captions.length==4?43:round?68:77;}
else if(state.mode.equals("py")){keyHeight=round?56:61;}
boolean qwerty=state.mode.equals("py")||state.mode.equals("en");
float common=Float.MAX_VALUE;
if(qwerty)for(int i=0;i<captions.length;i++)common=Math.min(common,(410-2*inset(keyY+i*(keyHeight+4),keyHeight))/captions[i].length);
for(int i=0;i<captions.length;i++){
float y=keyY+i*(keyHeight+4);
if(!qwerty){row(captions[i],values[i],y,keyHeight,captions[i].length>=7?21:24);continue;}
float left=(410-common*captions[i].length)/2;
for(int j=0;j<captions[i].length;j++)action(captions[i][j],values[i][j],left+j*common+1,y,common-2,keyHeight,21,false);
}
float modeY=keyY+captions.length*(keyHeight+4)+3;
row(new String[]{"九宫","26键","14键",state.upper?"ABC":"abc","123","符号"},new String[]{"mode:t9","mode:py","mode:14","mode:en","mode:num","mode:sym"},modeY,27,13);
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(state.candidates.size()+" 项 · "+(state.page+1)+" / "+Math.max(1,count),12,MUTED),safe,y,410-2*safe,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(item.word,item.word,entry.span==1?25:entry.span==2?23:20,false,()->actions.choose(item));
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;
gridY+=row.units*unit;rowIndex++;
}
float pagerY=y+25+4*unit+3;
row(new String[]{"‹","›"},new String[]{"previous","next"},pagerY,29,27);
@@ -148,7 +199,7 @@ final class KeyboardSurface extends ViewGroup {
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(state.secret?"私密输入 · 不学习、不保存":"完全离线 · 不采集输入内容",12,MUTED),45,y+150,320,26);
place(label("privacy",state.secret?"私密输入 · 不学习、不保存":"完全离线 · 不采集输入内容",12,MUTED),45,y+150,320,26);
bottom(y+190,true);
}
private void bottom(float y,boolean back) {
@@ -156,8 +207,6 @@ final class KeyboardSurface extends ViewGroup {
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);
// Preview taps open cursor tools without taking an extra keyboard key.
for(Placement placement:placements)if(placement.view instanceof TextView&&!(placement.view instanceof Button)&&placement.view.getContentDescription()==null){if(((TextView)placement.view).getText().toString().equals(state.secret?"私密输入":state.preview.isEmpty()?"开始输入…":state.preview)){placement.view.setOnClickListener(v->actions.action("tools"));placement.view.setContentDescription("编辑光标");}}
}
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 · 再点符号翻页":"数字";}
@@ -165,11 +214,11 @@ final class KeyboardSurface extends ViewGroup {
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);
if(width!=builtWidth||height!=builtHeight||!layoutKey().equals(builtKey)||state.expanded&&builtCandidates!=state.candidates)build(width,height);else if(!state.expanded&&!state.tools)updateContent();
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(){actions.repeatDelete(false);super.onDetachedFromWindow();}
@Override protected void onDetachedFromWindow(){removeCallbacks(render);refreshPosted=false;actions.repeatDelete(false);super.onDetachedFromWindow();}
}
@@ -10,6 +10,8 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import android.os.CancellationSignal;
import java.util.regex.Pattern;
/** Full upstream dictionary, offline. Called only on the service's single worker. */
public final class Lexicon implements AutoCloseable {
@@ -17,6 +19,7 @@ public final class Lexicon implements AutoCloseable {
public static final String[] GROUP9={"abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
private final Context context;
private SQLiteDatabase db;
private static final Pattern CONTEXT=Pattern.compile("[\\u3400-\\u9fff]{1,4}$");
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";
@@ -29,9 +32,9 @@ public final class Lexicon implements AutoCloseable {
}
private void open() throws Exception {
if(db!=null)return;
File directory=context.getNoBackupFilesDir();File target=new File(directory,"crab-v5-1.db");
File directory=context.getNoBackupFilesDir();File target=new File(directory,"crab-v5-2.db");
if(!target.isFile()) {
File temporary=new File(directory,"crab-v5-1.tmp");
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();
}
@@ -40,6 +43,29 @@ public final class Lexicon implements AutoCloseable {
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 {
return predict(context,null);
}
public List<Candidate> predict(String context,CancellationSignal signal) throws Exception {
if(signal!=null)signal.throwIfCanceled();
List<Candidate> result=new ArrayList<>();
java.util.regex.Matcher match=CONTEXT.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)},signal)){
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 {
return lookup(input,mode,context,null);
}
public List<Candidate> lookup(String input,String mode,String context,CancellationSignal signal) throws Exception {
if(signal!=null)signal.throwIfCanceled();
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();
@@ -48,13 +74,25 @@ public final class Lexicon implements AutoCloseable {
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) FROM entries e JOIN words w ON w.entry=e.id WHERE e."+column+" IN ("+marks+") ORDER BY "+order,prefixes)) {
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,signal)) {
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));
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)},signal)){
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,signal));
return result;
}
@Override public void close() { if(db!=null){db.close();db=null;} }
@@ -20,6 +20,13 @@ public class CandidatePagerTest {
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 largeDictionaryMaterializesOnlyTheRequestedPage(){
List<Candidate> input=new ArrayList<>();for(int i=0;i<12000;i++)input.add(new Candidate(i%13==0?"完整词组":String.valueOf((char)(0x4E00+i%1200)),3));
List<List<CandidatePager.Row>> pages=CandidatePager.pages(input);List<Candidate> restored=new ArrayList<>();
assertTrue(pages.size()>700);assertNotSame("Pages are created on demand",pages.get(0),pages.get(0));
for(List<CandidatePager.Row> page:pages){int cells=0,units=0;for(CandidatePager.Row row:page){units+=row.units;for(CandidatePager.Cell cell:row.cells){restored.add(cell.candidate);cells++;}}assertTrue(cells<=16);assertTrue(units<=4);}
assertEquals("No candidate is omitted or reordered",input,restored);
}
@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());
}
}
+8 -3
查看文件
@@ -1,5 +1,5 @@
"""Losslessly build the Android SQLite asset from the licensed original V5 trie."""
import argparse,hashlib,json,pathlib,sqlite3
import argparse,hashlib,importlib.util,json,pathlib,sqlite3
APP=pathlib.Path(__file__).resolve().parents[1]
def build(rtos):
@@ -22,6 +22,9 @@ def build(rtos):
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}
@@ -33,13 +36,15 @@ def build(rtos):
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.executescript('CREATE INDEX entries_code9 ON entries(code9);CREATE INDEX entries_code14 ON entries(code14);ANALYZE;PRAGMA user_version=1;')
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={'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()}
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))
+15 -9
查看文件
@@ -1,9 +1,10 @@
"""Install the release and run embedded-component instrumentation on one explicit device."""
import argparse,hashlib,json,pathlib,subprocess,time
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
@@ -14,26 +15,31 @@ def main():
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/'app/build/outputs/apk/androidTest/release/app-release-androidTest.apk'
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 [apk,test,host]:print(adb('install','-r',str(path),timeout=90),flush=True)
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)
adb('shell','input','keyevent','224');adb('shell','wm','dismiss-keyguard')
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','com.sydxky.crabinput.test/androidx.test.runner.AndroidJUnitRunner']
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)
passed=result.returncode==0 and 'OK (8 tests)'in output and 'FAILURES!!!'not in output
report={'model':model,'sdk':sdk,'version':'1.2.0','apk_sha256':hashlib.sha256(apk.read_bytes()).hexdigest(),'instrumentation_tests':8,'embedded_component':True,'system_ime_unchanged':adb('shell','settings','get','secure','default_input_method')==before,'passed':passed}
expected=7 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'
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))
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()
+18 -1
查看文件
@@ -24,7 +24,23 @@ $targets = if ($QuickAppOnly) {
}
$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 {
$env:Path = "$toolBin;$originalPath"
$availableDevices = @(& $gx list device)
@@ -77,5 +93,6 @@ try {
}
} finally {
Pop-Location
if ($mappedDrive) { & $subst $mappedDrive /D | Out-Null }
$env:Path = $originalPath
}
+2
查看文件
@@ -4,6 +4,8 @@
正在编辑的文本仅在本次页面的内存中。主动“保存草稿”后,普通文本保存于本应用本地存储,供下次启动恢复;可清空全文后重新保存以删除旧草稿,卸载应用也会清除该存储。私密输入不提供保存、不参与学习,切换其他应用或销毁页面时清空编辑状态。
离线上下文联想仅使用本次编辑光标前最多4个连续汉字,不跨标点或非中文字符,不保存联想上下文;密码/私密输入不参与。续写索引来自已附许可的现有词库与本项目自编的少量常用短语,不增加网络请求。
候选学习默认关闭。开启后仅在本应用保存最多 100 组编码、每组三个主动选择的真实候选;不会保存全文。在编辑页可关闭学习或“清除学习”。关闭学习不会自动删除旧记录,可通过清除按钮删除。
嵌入其他 Glyphix 应用后,键盘数据保存在该宿主自己的隔离空间。完成输入会将文本回填给宿主;宿主后续的网络行为适用其自身说明。腕上浏览的兼容模式会在明确告知后把网页输入交给服务器,详见其独立隐私政策。
+18 -4
查看文件
@@ -2,7 +2,15 @@
**蟹蟹输入 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.2.0 / 10200`。基于 [CrabKeyboard](https://github.com/crabPangXie/crabKeyboard) 的 MIT 全量词库和拼音前缀消费方式,重写为 Glyphix `.ux` 页面;不依赖 HarmonyOS HML、Android 输入法服务、DOM 或网络。
独立应用 `com.sydxky.rtos.inputmethod`,版本 `1.2.5 / 10205`。基于 [CrabKeyboard](https://github.com/crabPangXie/crabKeyboard) 的 MIT 全量词库和拼音前缀消费方式,重写为 Glyphix `.ux` 页面;不依赖 HarmonyOS HML、Android 输入法服务、DOM 或网络。
1.2.5 降低查词及候选开销:2,158 B运行索引,3个分片/48,000字符LRU,去掉整份词库复制,按需生成当前候选页,隐藏即释放缓存。26字母统一大小并放大,圆屏同步调整留白、候选栏和底部操作。详见[本版验收](./VALIDATION_1.2.5.md)。
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),独立包名、无需联网,接入应用的输入框自动弹出,保持系统默认输入法。
@@ -16,8 +24,8 @@
- 点击首页文字区或“开始输入”即打开满屏键盘。完成回填;取消保留原文。草稿需主动保存,仅保存在本应用。
- 默认九宫大按键。“26键”切到标准 QWERTY 全拼,`v` 表示 `ü`;“九宫”切回九宫。“14键”直接进入双字母模式。连续点击对应字母组,选择候选后只消费匹配部分,剩余编码继续查词。存在未选词的九宫或14键编码时禁止切换模式/完成,避免丢字或把编码当文字提交。
- 第一行显示三个候选。点击右侧下箭头按每页四项查看全部匹配候选,左右箭头翻页,不使用原项目扩展列表的 100 项截断。拼音候选保留每组原始顺序;九宫和14键歧义通过本地 jieba 0.42.1 词频排序,学习偏好可优先排序。
- 支持英文大小写、数字、完整可打印 ASCII 标点与常用中文标点。符号分两组大按键,通过“下一组符号”切换。网址额外提供 `https://`、`.com`。
- 第一行显示三个候选。点击右侧下箭头按4×4自适应分页查看全部匹配候选,左右箭头翻页,不使用原项目扩展列表的 100 项截断。完整拼音、九宫和14键先匹配完整编码,再提供未完成拼音补全和较短前缀;通过本地 jieba 0.42.1 词频及当前光标前的中文上下文排序,学习偏好可优先排序。
- 点“abc”进入英文;再点“大写”切换大写,点“小写”恢复小写,字母键标签与输入字符同步。支持数字、完整可打印 ASCII 标点与常用中文标点。符号分两组大按键,通过“下一组符号”切换。网址额外提供 `https://`、`.com`。
- 点击文本预览或铅笔图标移动光标、换行、上屏全拼、清空全文。Unicode 字符按码点计数和删除,避免拆坏 emoji。清空全文需第二次点击确认;长按退格图标连续删除,松开/隐藏/销毁停止。勾号完成,叉号取消,键盘图标返回输入。
- 学习默认关闭,可开启或清除,仅保存最多 100 组、每组三个已选择的真实候选,不记录整段正文。私密输入始终遮盖、不学习、不保存;切到其他应用时清空私密编辑状态。
@@ -62,8 +70,14 @@ python .\tests\visual.py --work '<另一个临时目录>' --output '<原生证
## 内存与词库
`convert-dictionary.py` 将原始 V5 trie 无损转换为 **6779** 个编码分片(九宫/全拼4194个、14键2585个),最大分片 **116,686** 字节。九宫/全拼使用四位前缀,14键使用三位前缀;短编码单独索引。解码缓存限制为四项且 JSON 文本合计不超过 90,000 字符,大分片不缓存。仅当前页最多16个候选进入展开网格,所有候选对象不放进响应式状态。转换器分别回读两种索引,验证与原始索引完全相同;不改写原始字典。
`convert-dictionary.py` 将原始 V5 trie 无损转换为 **6779** 个编码分片(九宫/全拼4194个、14键2585个),最大分片 **116,686** 字节。九宫/全拼使用四位前缀,14键使用三位前缀;短编码单独索引。解码缓存限制为三项且 JSON 文本合计不超过 48,000 字符,大分片不缓存。仅当前页最多16个候选进入展开网格,所有候选对象不放进响应式状态。转换器分别回读两种索引,验证与原始索引完全相同;不改写原始字典。
初期两位编码分片曾触发原生模拟器内存回收,已改为四位编码分片。验收保持既有 `--memory-profile 8192`,没有通过增大模拟器内存规避问题。模拟器耗时不能替代真机帧率、延迟或功耗结论。
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,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真机、固件接入、长期性能/功耗和商店审核未验收。
@@ -0,0 +1,23 @@
# RTOS 1.2.5 内存、查词及等大26键验收
2026-11-14,版本1.2.5/10205,8192 KiB原生模拟器配置。
运行索引从91,357 B审计meta.json改为2,158 B的存在位图runtime.json;完整meta、原始词库和第三方声明保留。缓存限制3个分片及48,000个JSON字符,真正LRU,大分片不缓存,页面隐藏释放;清理期间的旧异步读取不能重新填满缓存。宿主同步工具同时复制运行索引。
移除全词库entries/frequencies复制,26键直接定位输入前缀,九宫/14键保留相关分片消歧。补全只保留12个最优且未被完整匹配覆盖的候选,保留零词频补全。过期查询在读文件后结束。全部匹配结果仍可分页浏览;只有当前最多3项首页候选进入响应式数据,展开时建立整数页起点,翻页只创建当前最多16个单元;反应式字段40降至29。
26字母维持10/9/7三排,三排使用一致的宽高。方屏利用全宽;圆屏调整上下留白、候选栏与模式栏,扩大字母触控区且保留完整底部操作。独立1px光标和大写/小写行为保持。
## 算法与构建
59项引擎、49项UX、组件同步测试及普通/QuickApp四配置生产构建通过。词库保留104,837组索引、163,238候选,两种索引完整回读一致。24种编码的完整匹配及补全与1.2.4逐项一致;12,000个混合候选恢复为原顺序,808页且当前页最多15项;7,291个运行索引存在位与审计清单完全一致。另验证完整匹配/补全去重、零词频补全、旧清单兼容与清理隔离。
同一词库、24种全拼/九宫/14键编码,5轮共120次查询:总耗时 227.12 → 194.87 ms,P95 6.70 → 5.01 ms,中位数 1.01 → 1.07 ms;GC后保留堆 1,149,800 → 805,848 B。总耗时减少14.2%,P95减少25.2%,保留堆减少29.9%。中位数略增,不能称所有输入都提速。
这是Windows Node/V8的对比,数值随主机负载变化;不是Glyphix MCU/真实手表的内存、帧率或触控延迟。原始报告及执行脚本随源码/验收ZIP提供。
## 原生组件与视觉
生产CrabInput组件逐字节复制到独立自动测试入口工程,报告保留组件和路由摘要。圆/方普通和QuickApp四配置共96状态/96张原生截图通过。检查8种文本/光标/私密状态、两主题与全拼/英文26键;26键宽高取整误差最多1px。模式切换与空格/删除/完成按钮同时检查矩形及圆屏四角安全边界;人工核对圆屏底部控件没有被父容器裁切。每配置实际点击字母和大小写按钮提交qQq。未使用或接管Inspector。
这些是原生测试工程的组件回归,不是RTOS真实手表或最终发布包全套端到端验收。原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']
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):
number = 0
for ch in value:
@@ -14,7 +48,30 @@ def from94(value):
number = number * 94 + ord(ch) - 33
return number
def presence(names,alphabet,lengths,prefix):
masks=[]
for length in lengths:
mask=bytearray((len(alphabet)**length+7)//8)
for name in names:
code=name[len(prefix):] if name.startswith(prefix) else ''
if len(code)!=length or any(ch not in alphabet for ch in code):continue
index=0
for ch in code:index=index*len(alphabet)+alphabet.index(ch)
mask[index//8]|=1<<(index%8)
masks.append(mask.hex())
return masks
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')}
frequency_path=APP/'third_party/jieba/dict.txt'
frequencies={}
@@ -48,8 +105,8 @@ def convert(source, target):
# Only replace files listed in the previous generated manifest.
if (target/'meta.json').exists():
previous=json.loads((target/'meta.json').read_text(encoding='utf-8'))
for name in previous['shards']:
assert re.fullmatch(r'(?:short|s?[0-9]+|ds?[0-9a-d]+)',name)
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]+|[ac][0-9a-f]{2})',name)
old=target/(name+'.json')
if name not in shards and old.exists():old.unlink()
for name, content in shards.items():
@@ -57,20 +114,38 @@ def convert(source, target):
output=target/(name+'.json')
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)
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.
restored = {};restored14={}
for name in shards:
(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
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',
variant='pinyinDictV5_20260416', keys=len(entries),
candidates=sum(map(len, entries.values())), shards=sorted(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,
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())})
if frequency_path.exists():report['t9_frequency_source']={'project':'jieba 0.42.1','license':'MIT','sha256':hashlib.sha256(frequency_path.read_bytes()).hexdigest()}
# Runtime needs only presence bits, not thousands of filenames and audit data.
runtime={'version':1,'short':presence(shards,'23456789',range(1,4),'s'),
'long':presence([name for name in shards if re.fullmatch('[2-9]{4}',name)],'23456789',[4],''),
'dualShort':presence(shards,'0123456789abcd',range(1,3),'ds'),
'dualLong':presence([name for name in shards if re.fullmatch('d[0-9a-d]{3}',name)],'0123456789abcd',[3],'d'),
'associations':presence(association_shards,'0123456789abcdef',[2],'a'),
'completions':presence(completion_shards,'0123456789abcdef',[2],'c')}
output=target/'runtime.json';data=(json.dumps(runtime,separators=(',',':'))+'\n').encode('utf-8')
if not output.exists() or output.read_bytes()!=data:output.write_bytes(data)
output=target/'meta.json';data=(json.dumps(report, ensure_ascii=False, indent=2)+'\n').encode('utf-8')
if not output.exists() or output.read_bytes()!=data:output.write_bytes(data)
print(json.dumps({**{k:v for k,v in report.items() if k not in ['source_files','shards']},'shards':len(shards)}, ensure_ascii=False))
+135 -20
查看文件
@@ -1,26 +1,141 @@
// 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 path from 'node:path'
import crypto from 'node:crypto'
import {spawnSync} from 'node:child_process'
import {fileURLToPath} from 'node:url'
const app=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..')
const target=path.resolve(process.argv[2]||'')
if(!process.argv[2]||!fs.existsSync(path.join(target,'src/manifest.json')))throw Error('Usage: node scripts/install.mjs <Glyphix project>')
const oldMeta=path.join(target,'src/assets/ime-dict/meta.json')
if(fs.existsSync(oldMeta)){
const current=JSON.parse(fs.readFileSync(path.join(app,'src/assets/ime-dict/meta.json'),'utf8')).shards
for(const name of JSON.parse(fs.readFileSync(oldMeta,'utf8')).shards){
if(!/^(short|s?[0-9]+|ds?[0-9a-d]+)$/.test(name))throw Error('Invalid generated shard name')
const old=path.join(target,'src/assets/ime-dict',name+'.json')
if(!current.includes(name)&&fs.existsSync(old))fs.unlinkSync(old)
const APP=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..')
const RECEIPT='src/assets/crab-input-source.json'
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)||!fs.existsSync(path.join(source,'src/assets/ime-dict/runtime.json')))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
}
for(const relative of ['keyboard/index.ux','common/ime-engine.js','common/ime-lifecycle.js','assets/ime-dict','assets/ime-icons']){
const destination=path.join(target,'src',relative);fs.mkdirSync(path.dirname(destination),{recursive:true});fs.cpSync(path.join(app,'src',relative),destination,{recursive:true})
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')
const runtime=path.join(source,'src/assets/ime-dict/runtime.json')
if(fs.existsSync(runtime))add('src/assets/ime-dict/runtime.json',runtime)
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')
if(!previous&&fs.existsSync(oldMeta)){
const oldDictionary=readJson(oldMeta),oldShards=[...oldDictionary.shards,...(oldDictionary.association_shards||[]),...(oldDictionary.completion_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')
previous={files:Object.fromEntries(oldShards.map(name=>['src/assets/ime-dict/'+name+'.json',{}]))}
}
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++
}
// Only delete recorded, owned files; never touch host assets or business data.
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,8 @@ export function digitsOf(text) {
return result
}
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
@@ -43,6 +45,23 @@ export function candidatePages(candidates) {
}
flushPage();return pages
}
// A page plan retains integer offsets; only the visible page gets cell objects.
export function candidatePageStarts(candidates){
const starts=[];let used=0,slots=0,rowUnits=0
for(let i=0;i<candidates.length;i++){
const length=chars(candidates[i].word).length,span=length===1?1:length<=4?2:4,units=span===4?Math.max(1,Math.ceil(length/12)):1
if(slots&&(slots+span>4||rowUnits!==units)){used+=rowUnits;slots=0}
if(!starts.length||used+units>4){starts.push(i);used=0;slots=0}
if(!slots)rowUnits=units
slots+=span
if(slots===4){used+=rowUnits;slots=0}
}
return starts
}
export function candidatePageRows(candidates,starts,page){
if(!starts.length)return []
return candidatePages(candidates.slice(starts[page],page+1<starts.length?starts[page+1]:candidates.length))[0]||[]
}
export function edit(text,cursor,insertion,maxLength) {
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)
@@ -53,11 +72,21 @@ export function erase(text,cursor) {
if(at)value.splice(at-1,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 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) {
const result={}
if(!input||typeof input!=='object'||Array.isArray(input))return result
@@ -78,49 +107,113 @@ export function learn(preferences,input,candidate) {
}
export function createDictionary(read) {
// Small shards and a bounded LRU avoid parsing the whole lexicon on an MCU.
const cache=[],pending={};let manifest=null
const cache=[];let pending={},manifest=null,metadataTask=null,epoch=0,cacheChars=0,reads=0,readChars=0,hits=0
function hasBits(hex,code,alphabet){let index=0;for(const ch of code){const digit=alphabet.indexOf(ch);if(digit<0)return false;index=index*alphabet.length+digit}const byte=parseInt(hex.slice((index>>3)*2,(index>>3)*2+2),16)||0;return !!(byte&(1<<(index&7)))}
function exists(name){
if(manifest.version!==1)return ((name[0]==='a'?manifest.association_shards:name[0]==='c'?manifest.completion_shards:manifest.shards)||[]).indexOf(name)>=0
if(name[0]==='a'||name[0]==='c')return hasBits(manifest[name[0]==='a'?'associations':'completions'][0],name.slice(1),'0123456789abcdef')
const dual=name[0]==='d',short=name[dual?1:0]==='s',code=name.slice(dual?(short?2:1):(short?1:0))
const masks=manifest[dual?(short?'dualShort':'dualLong'):(short?'short':'long')]
const mask=masks&&masks[short?code.length-1:0]
return !!mask&&hasBits(mask,code,dual?'0123456789abcd':'23456789')
}
async function readJson(name){const text=await read('/assets/ime-dict/'+name+'.json');reads++;if(typeof text==='string')readChars+=text.length;return {data:typeof text==='string'?JSON.parse(text):text,size:typeof text==='string'?text.length:0}}
async function load(name) {
const existing=cache.find(x=>x.name===name)
if(existing)return existing.data
const index=cache.findIndex(x=>x.name===name)
if(index>=0){const existing=cache.splice(index,1)[0];cache.push(existing);hits++;return existing.data}
if(pending[name])return pending[name]
pending[name]=(async()=>{
const text=await read('/assets/ime-dict/'+name+'.json')
const data=typeof text==='string'?JSON.parse(text):text
const size=typeof text==='string'?text.length:0
cache.push({name,data,size});while(cache.length>4||cache.reduce((sum,x)=>sum+x.size,0)>90000)cache.shift()
return data
const owner=epoch,task=(async()=>{
const entry=await readJson(name)
if(owner===epoch&&entry.size<=48000){cache.push({name,data:entry.data,size:entry.size});cacheChars+=entry.size;while(cache.length>3||cacheChars>48000)cacheChars-=cache.shift().size}
return entry.data
})()
try{return await pending[name]}finally{delete pending[name]}
pending[name]=task
try{return await task}finally{if(pending[name]===task)delete pending[name]}
}
async function metadata(){
if(manifest)return manifest
if(!metadataTask){const owner=epoch;metadataTask=(async()=>{let entry;try{entry=await readJson('runtime')}catch(error){entry=await readJson('meta')}if(owner===epoch)manifest=entry.data;return entry.data})()}
return metadataTask
}
async function predict(context,current){
const owner=epoch,previous=current;current=()=>owner===epoch&&(!previous||previous())
const tail=contextTail(context);if(!tail)return []
const meta=await metadata(),result=[],seen={}
if(current&&!current())return []
for(let length=tail.length;length>0&&result.length<8;length--){
const prefix=tail.slice(-length),name='a'+predictiveBucket(prefix)
if(!exists(name))continue
const rows=(await load(name))[prefix]||[]
if(current&&!current())return []
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 {
async lookup(raw,t9,preferences) {
predict,
async lookup(raw,t9,preferences,context,current) {
const owner=epoch,previous=current;current=()=>owner===epoch&&(!previous||previous())
const input=String(raw||'').toLowerCase()
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 []
const digits=ambiguous?input:digitsOf(input)
const merged={},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()
if(current&&!current())return []
const names=[]
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))
if(digits.length>=prefixLength)names.push((dual?'d':'')+digits.slice(0,prefixLength))
for(const name of names)if(manifest.shards.indexOf(name)>=0)try{merge(await load(name))}catch(error){throw new Error('词库读取失败')}
const matching=Object.keys(merged).filter(key=>input.indexOf(dual?code14Of(key):t9?digitsOf(key):key)===0)
matching.sort((a,b)=>b.length-a.length||(a<b?-1:a>b?1:0))
const result=[],seen={}
for(const key of matching)for(const word of merged[key]){
const identity=word+'@'+key.length
if(!seen[identity]){seen[identity]=true;result.push({word,pinyin:key,consume:key.length})}
const matching=[],extras=[],result=[],seen={};let completionData=null
function offer(word,key,frequency){
if(seen[word+'@'+input.length])return
const missing=key.length-input.length,old=extras.findIndex(x=>x.word===word)
if(old>=0){if(extras[old].frequency>frequency||extras[old].frequency===frequency&&extras[old].missing<=missing)return;extras.splice(old,1)}
let at=0;while(at<extras.length&&(extras[at].frequency>frequency||extras[at].frequency===frequency&&extras[at].missing<=missing))at++
if(at<12){extras.splice(at,0,{word,pinyin:key,frequency,missing,consume:input.length,completion:true});if(extras.length>12)extras.pop()}
}
for(const name of names)if(exists(name)){
const data=await load(name)
if(current&&!current())return []
if(!ambiguous){
for(let length=1;length<=input.length;length++){const key=input.slice(0,length);if(data.entries[key])matching.push({key,words:data.entries[key],weights:data.frequencies})}
}
if(input.length>=prefixLength)completionData=data
if(ambiguous)for(const key in data.entries){
const code=dual?code14Of(key):t9?digitsOf(key):key
if(input.indexOf(code)===0)matching.push({key,words:data.entries[key],weights:data.frequencies})
}
}
matching.sort((a,b)=>b.key.length-a.key.length||(a.key<b.key?-1:a.key>b.key?1:0))
for(const entry of matching)for(const word of entry.words){const identity=word+'@'+entry.key.length;if(!seen[identity]){seen[identity]=true;result.push({word,pinyin:entry.key,consume:entry.key.length,frequency:entry.weights[word]||0})}}
if(input.length>=2){
const mode=dual?'14':t9?'t9':'py',prefix=mode+':'+input
if(input.length<prefixLength){
const name='c'+predictiveBucket(prefix)
if(exists(name)){
const rows=(await load(name))[prefix]||[]
if(!current())return []
for(const row of rows)offer(row[0],row[1],row[2])
}
}else if(completionData)for(const key in completionData.entries){
const code=dual?code14Of(key):t9?digitsOf(key):key
if(code.length>input.length&&code.length<=input.length+4&&code.indexOf(input)===0)for(const word of completionData.entries[key])offer(word,key,completionData.frequencies[word]||0)
}
for(const item of extras){const identity=item.word+'@'+item.consume;if(!seen[identity]){seen[identity]=true;result.push(item)}}
}
const preferenceKey=dual?'k14:'+input:input
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}
// Rank only real dictionary results: a damaged preference cannot inject text.
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 ranks={},contexts={};for(let i=0;i<prefs.length;i++)ranks[prefs[i].word+'@'+prefs[i].consume]=i
const predictions=await predict(context,current);if(current&&!current())return []
for(let i=0;i<predictions.length;i++)contexts[predictions[i].word]=8-i
const rank=item=>{const value=ranks[item.word+'@'+item.consume];return value===undefined?100:value},contextRank=item=>contexts[item.word]||0
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.
for(let i=0;i<result.length;i++)result[i].order=i
return result.sort((a,b)=>rank(a)-rank(b)||category(a)-category(b)||b.consume-a.consume||contextRank(b)-contextRank(a)||(b.frequency||0)-(a.frequency||0)||a.order-b.order)
},
clear(){cache.length=0;manifest=null},
get cacheSize(){return cache.length}
clear(){epoch++;cache.length=0;cacheChars=0;pending={};manifest=null;metadataTask=null},
get cacheSize(){return cache.length},
get stats(){return {reads,readChars,hits,cacheChars,cacheEntries: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) {
for(const input of activeInputs())input.onHide()
for(const page of router.getPages()) {
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,167 @@
<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'" id="ime-keypad" class="keypad qwerty-pad pinyin-pad">
<div class="keyrow qwerty-first"><button for="key in keyRows[0]" :id="'ime-key-' + key.value.toLowerCase()" class="key qwerty-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 qwerty-key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
<div class="keyrow qwerty-last"><button for="key in keyRows[2]" :id="'ime-key-' + key.value.toLowerCase()" class="key qwerty-key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
</div>
<div if="mode === 'en'" id="ime-keypad" class="keypad qwerty-pad english-pad">
<div class="keyrow qwerty-first"><button for="key in keyRows[0]" :id="'ime-key-' + key.value.toLowerCase()" class="key qwerty-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 qwerty-key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
<div class="keyrow qwerty-last"><button for="key in keyRows[2]" :id="'ime-key-' + key.value.toLowerCase()" class="key qwerty-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-pad{width:100%}.qwerty-first{width:100%}.qwerty-middle{width:90%}.qwerty-last{width:70%}.qwerty-key{margin:2px;height:58px}.english-pad .qwerty-key{height:76px}
.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){.ime{padding:22px 8px}.candidates{height:32px}.candidate,.more{height:26px}.modes{margin-top:1px}.qwerty-pad{width:98%}.pinyin-pad .qwerty-key{height:54px}.english-pad{width:92%}.english-pad .qwerty-key{height:65px}.bottom{width:176px}}
</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,candidatePageStarts,candidatePageRows,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:'蟹蟹输入',themeStyles:stylesForTheme('bluewhite'),inputType:'text',allowNewline:true,secret:false,composition:'',compositionDisplay:'',previewBefore:'',previewAfter:'',count:0,mode:'t9',modeLabel:'九宫拼音',upper:false,symbolPage:0,panel:'keys',keyRows:[],layoutRows:[],keyStyle:'',labelStyle:'',bar:[],candidateRows:[],total:0,candidatePage:0,pageCount:1,busy:false,hint:'离线全量词库',learning:false,clearArmed:false},
title:'蟹蟹输入',inputTheme:'bluewhite',initial:'',maxLength:2048,revealSecret:false,text:'',cursor:0,display:'',limit:2048,visibleCandidates:[],
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]
this.allCandidates=[];this.pages=[];this.visibleCandidates=[];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.dictionary)this.dictionary.clear();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=(qwerty?'':'height:'+(this.keyRows.length<=3?'49':this.keyRows.length===4?'38':this.keyRows.length===5?'28':'22')+'px;')+this.themeStyles.card
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 current=()=>life.active&&id===life.id;const result=raw?await this.dictionary.lookup(raw,this.mode==='14'?'14':this.mode==='t9',this.learning?this.preferences:{},context,current):await this.dictionary.predict(context,current)
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=[];this.pageCount=1;this.candidatePage=0;if(this.panel==='candidates')this.pageCandidates();else{this.visibleCandidates=[];this.candidateRows=[]}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(){if(!this.pages.length)this.pages=candidatePageStarts(this.allCandidates);this.pageCount=Math.max(1,this.pages.length);const rows=candidatePageRows(this.allCandidates,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>
+16 -143
查看文件
@@ -1,145 +1,18 @@
<template>
<div class="ime-surface">
<div class="ime">
<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 if="panel !== 'candidates'" 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"><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">{{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}.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: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:340px}.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}}
</style>
<import name="CrabInput" src="../components/CrabInput/index" />
<template><div class="keyboard-page"><CrabInput :config="options" on:complete="complete($event)" on:cancel="cancel" /></div></template>
<style>.keyboard-page{display:flex;flex-direction:column;width:100%;height:100%}</style>
<script>
import file from '@system.file'
import storage from '@system.storage'
import router from '@system.router'
import {createDictionary,chars,candidatePages,edit,erase,preview,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:{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:[],candidateRows:[],total:0,candidatePage:0,pageCount:1,busy:false,hint:'离线全量词库',learning:false,clearArmed:false},
dictionary:null,preferences:{},allCandidates:[],pages:[],active:true,life:null,requestId:0,lookupTimer:null,repeatTimer:null,repeatDelay:null,result:null,original:'',
onInit(){
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;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.candidateRows=[];this.pages=[];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.candidateRows=[];this.pages=[];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.pages=candidatePages(result);this.pageCount=Math.max(1,this.pages.length);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(){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',labelStyle:'font-size:'+(entry.span===1?25:entry.span===2?23:20)+'px'}})}))},
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)}
}
import router from '@system.router'
import {activeInputs} from '../common/ime-lifecycle.js'
export default {
data:{initial:'',title:'蟹蟹输入',inputType:'text',maxLength:2048,allowNewline:true,inputTheme:'bluewhite',secret:false,revealSecret:false,options:{}},
result:null,
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}},
onShow(){for(const input of activeInputs())if(input.$page===this.$page)input.onShow()},
onHide(){for(const input of activeInputs())if(input.$page===this.$page)input.onHide()},
clearPrivate(){for(const input of activeInputs())if(input.$page===this.$page&&input.secret)input.clearPrivate();this.initial='';this.options.initial=''},
complete(result){this.result={accepted:true,text:result.text};router.close(this.$page,this.result)},
cancel(){this.result={accepted:false};router.close(this.$page,this.result)},
onDestroy(){this.initial='';this.options={};router.close(this.$page,this.result||{accepted:false});this.result=null}
}
</script>
+16 -4
查看文件
@@ -2,9 +2,21 @@
"package": "com.sydxky.rtos.inputmethod",
"name": "蟹蟹输入",
"icon": "/assets/icon.png",
"versionName": "1.2.0",
"versionCode": 10200,
"versionName": "1.2.5",
"versionCode": 10205,
"features": [],
"router": {"entry": "main", "pages": {"main": {"component": "index"}, "keyboard": {"component": "index"}}},
"config": {"designWidth": 410}
"router": {
"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 assert from 'node:assert/strict'
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=')
let count=0
const closed=[],store=new Map(),timers=new Map();let nextTimer=1,storageFails=false
function check(ok,label){assert.ok(ok,label);count++}
function page(params={}){
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)})
const p={...module.exports,...JSON.parse(JSON.stringify(module.exports.data)),...params};delete p.data;p.onInit();return p
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={$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')
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,10 @@ 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=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',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.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),clear(){}};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.finish();check(p.composition==='nihao','finish does not silently drop uncommitted composition')
@@ -42,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.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({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'}))
@@ -0,0 +1,115 @@
"""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)});
var controls=[];for(var id of ['ime-chinese','ime-14','ime-english','ime-tools','ime-space','ime-delete','ime-finish'])controls.push({key:id,rect:await rect(id)});
console.log('PROBE_CAPTURE '+JSON.stringify({name:theme+'-'+mode+'-'+(upper?'upper':'lower'),theme:theme,kind:'qwerty',keys:keys,controls:controls}));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=[]
widths=[];heights=[]
for key in state['keys']:
x,y,w,h=rectangle(key['rect'])
widths.append(w);heights.append(h)
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'])
equal=max(widths)-min(widths)<=1 and max(heights)-min(heights)<=1
for control in state['controls']:
x,y,w,h=rectangle(control['rect'])
if w<=0 or h<=0 or x<0 or y<0 or x+w>width or y+h>height:failures.append(control['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(control['key'])
state.update(failures=failures,key_width_px=[min(widths),max(widths)],key_height_px=[min(heights),max(heights)],equal_size=equal,passed=len(state['keys'])==26 and not failures and equal)
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 assert from 'node:assert/strict'
import {fileURLToPath} from 'node:url'
import {createDictionary,digitsOf,code14Of,KEY14_GROUPS,display14,candidatePages,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')
let count=0
function check(ok,label){assert.ok(ok,label);count++}
@@ -55,5 +55,21 @@ check(erase('你😀好',2).text==='你好','Unicode backspace preserves surroga
check(edit('你好',2,'世界',3).limited,'maximum length enforced')
check(!preview('my-secret',5,true).includes('secret'),'secret preview masked')
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')
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'}))
+11 -2
查看文件
@@ -17,12 +17,13 @@ def inspect(path,profile):
visible_pages(frame['widgets'])
page=pages[-1]
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):
g=node['geometry'];x=px+g['x'];y=py+g['y'];w=g['w'];h=g['h']
visible=visible and node.get('visible',True)
if not visible:return
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']:
for ancestor in ancestors:
ax,ay,aw,ah=ancestor['box']
@@ -36,4 +37,12 @@ def inspect(path,profile):
(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)
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}
+11 -9
查看文件
@@ -45,27 +45,29 @@ def main():
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]))
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("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()}")
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('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');")
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');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=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('你好');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('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('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-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('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('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('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('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('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('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=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=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('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:
@@ -0,0 +1,65 @@
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import {pathToFileURL,fileURLToPath} from 'node:url'
import assert from 'node:assert/strict'
import * as current from '../src/common/ime-engine.js'
const app=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..')
const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1]
assert(args.includes('--baseline'),'Use --baseline <v1.2.4 ime-engine.js>')
const baseline=await import(pathToFileURL(path.resolve(option('--baseline'))))
const cases=[]
for(const text of ['shi','shij','shijie','niha','nihao','beij','beijing','zhongguo'])for(const mode of [false,true,'14'])cases.push([mode===false?text:mode==='14'?current.code14Of(text):current.digitsOf(text),mode])
const identity=rows=>rows.map(x=>x.word+'@'+x.pinyin+'@'+x.consume+'@'+!!x.completion).sort()
async function sample(module){
let reads=0,readChars=0,largestRead=0,resultCount=0
const dictionary=module.createDictionary(async uri=>{const text=fs.readFileSync(app+'/src'+uri,'utf8');reads++;readChars+=text.length;largestRead=Math.max(largestRead,text.length);return text})
if(global.gc)global.gc()
const before=process.memoryUsage().heapUsed,times=[],rows=[]
for(let round=0;round<5;round++)for(const [raw,mode]of cases){
const start=performance.now(),result=await dictionary.lookup(raw,mode,{},'你好');times.push(performance.now()-start)
if(!round)rows.push(identity(result));resultCount+=result.length
}
if(global.gc)global.gc()
const retainedHeapBytes=process.memoryUsage().heapUsed-before
const sorted=times.slice().sort((a,b)=>a-b)
const metrics={queries:times.length,resultCount,reads,readChars,largestRead,retainedHeapBytes,medianMs:sorted[Math.floor(sorted.length/2)],p95Ms:sorted[Math.floor(sorted.length*.95)],totalMs:times.reduce((a,b)=>a+b,0),stats:dictionary.stats||null}
dictionary.clear();return {metrics,rows}
}
const old=await sample(baseline),now=await sample(current)
assert.deepEqual(now.rows,old.rows,'Exact candidates and completions survive optimization in all three layouts')
assert.equal(now.metrics.resultCount,old.metrics.resultCount)
const meta=JSON.parse(fs.readFileSync(app+'/src/assets/ime-dict/meta.json','utf8'))
const runtime=JSON.parse(fs.readFileSync(app+'/src/assets/ime-dict/runtime.json','utf8'))
const masks=[['short','s','23456789'],['long','','23456789'],['dualShort','ds','0123456789abcd'],['dualLong','d','0123456789abcd'],['associations','a','0123456789abcdef'],['completions','c','0123456789abcdef']]
const restoredNames=[]
for(const [field,prefix,alphabet]of masks)for(let mask=0;mask<runtime[field].length;mask++){
const length=field==='short'||field==='dualShort'?mask+1:field==='long'?4:field==='dualLong'?3:2,bits=runtime[field][mask]
for(let i=0;i<alphabet.length**length;i++)if(parseInt(bits.slice((i>>3)*2,(i>>3)*2+2),16)&1<<(i&7)){
let code='',value=i;for(let digit=0;digit<length;digit++){code=alphabet[value%alphabet.length]+code;value=Math.floor(value/alphabet.length)}restoredNames.push(prefix+code)
}
}
assert.deepEqual(restoredNames.sort(),[...meta.shards,...meta.association_shards,...meta.completion_shards].sort(),'Every runtime presence bit matches the audit manifest, including absent buckets')
// Duplicate exact words must not consume the bounded completion budget. Legacy
// manifests without predictive lists remain compatible; zero-frequency rows survive.
const source={meta:{shards:['2223']},'2223':{entries:{abcd:['同'],abcde:['同',...Array.from({length:15},(_,i)=>'补'+i)]},frequencies:{同:999}}}
const edge=current.createDictionary(async uri=>{const name=path.basename(uri,'.json');if(!(name in source))throw new Error('absent');return JSON.stringify(source[name])})
const edgeRows=await edge.lookup('abcd',false,{},'')
assert.equal(edgeRows.filter(x=>x.completion).length,12);assert.equal(edgeRows.filter(x=>x.word==='同').length,1)
assert((await edge.predict('你')).length===0);edge.clear()
const candidates=Array.from({length:12000},(_,i)=>({word:i%13===0?'完整词组':String.fromCodePoint(0x4e00+i%1200)}))
const plan=current.candidatePageStarts(candidates),restored=[]
let maxVisible=0
for(let page=0;page<plan.length;page++){
const rows=current.candidatePageRows(candidates,plan,page),visible=rows.flatMap(row=>row.items.map(cell=>cell.item));restored.push(...visible);maxVisible=Math.max(maxVisible,visible.length)
assert(rows.reduce((sum,row)=>sum+row.units,0)<=4)
}
assert.deepEqual(restored,candidates);assert(maxVisible<=16)
// A clear while file I/O is pending must not refill a closed session's cache.
let release;const paused=current.createDictionary(uri=>uri.endsWith('runtime.json')?new Promise(resolve=>release=resolve):fs.readFileSync(app+'/src'+uri,'utf8'))
const obsolete=paused.lookup('shi',false,{},'',()=>false);paused.clear();release(fs.readFileSync(app+'/src/assets/ime-dict/runtime.json','utf8'));assert.equal((await obsolete).length,0);assert.equal(paused.cacheSize,0)
const reactive=text=>{const source=text.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .+$/gm,'').replace('export default','module.exports=');const module={exports:{}};vm.runInNewContext(source,{module,stylesForTheme:()=>({})});return Object.keys(module.exports.data).length}
const report={method:'Node V8 comparison with identical dictionary; heap figures are host measurements, not MCU or watch RAM',exactCandidateCases:cases.length,completionCases:cases.length,runtimePresenceShards:restoredNames.length,baseline:old.metrics,optimized:now.metrics,pagePlan:{candidates:candidates.length,pages:plan.length,maxVisibleCells:maxVisible},runtimeIndexBytes:fs.statSync(app+'/src/assets/ime-dict/runtime.json').size,auditMetadataBytes:fs.statSync(app+'/src/assets/ime-dict/meta.json').size,reactiveProperties:{baseline:args.includes('--baseline-component')?reactive(fs.readFileSync(option('--baseline-component'),'utf8')):null,optimized:reactive(fs.readFileSync(app+'/src/components/CrabInput/index.ux','utf8'))},passed:true}
if(args.includes('--output'))fs.writeFileSync(option('--output'),JSON.stringify(report,null,2)+'\n')
console.log(JSON.stringify(report))
@@ -0,0 +1,55 @@
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','{}');write(root,'src/assets/ime-dict/runtime.json','{"version":1}')
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,14);assert.equal(first.removed,1);checkInput(host,source)
assert.equal(fs.readFileSync(path.join(host,'src/assets/ime-dict/runtime.json'),'utf8'),'{"version":1}','Compact runtime index is included in host synchronization')
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})
}
+1 -1
查看文件
@@ -50,7 +50,7 @@ def main():
try:
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()")
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')
emu.evaluate("p.composition='shi';p.mode='py';p.renderKeys();p.schedule()")
time.sleep(.5);capture(profile,'pinyin')
二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

+48
查看文件
@@ -0,0 +1,48 @@
{
"method": "Node V8 comparison with identical dictionary; heap figures are host measurements, not MCU or watch RAM",
"exactCandidateCases": 24,
"completionCases": 24,
"runtimePresenceShards": 7291,
"baseline": {
"queries": 120,
"resultCount": 25845,
"reads": 681,
"readChars": 3294647,
"largestRead": 91357,
"retainedHeapBytes": 1149800,
"medianMs": 1.0108999999999924,
"p95Ms": 6.702500000000001,
"totalMs": 227.1215,
"stats": null
},
"optimized": {
"queries": 120,
"resultCount": 25845,
"reads": 681,
"readChars": 3205448,
"largestRead": 82520,
"retainedHeapBytes": 805848,
"medianMs": 1.0670999999999822,
"p95Ms": 5.014200000000017,
"totalMs": 194.86530000000027,
"stats": {
"reads": 681,
"readChars": 3205448,
"hits": 0,
"cacheChars": 34340,
"cacheEntries": 3
}
},
"pagePlan": {
"candidates": 12000,
"pages": 808,
"maxVisibleCells": 15
},
"runtimeIndexBytes": 2158,
"auditMetadataBytes": 91357,
"reactiveProperties": {
"baseline": 40,
"optimized": 29
},
"passed": true
}
二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

+77 -15
查看文件
@@ -1,20 +1,82 @@
# 1.2.0 实际界面
# 1.2.5 RTOS原生组件截图
Android 图来自 OWW261 真机,RTOS 图来自 Glyphix 原生模拟器。预览拼图仅移除 RTOS 模拟器外壳,以下保留完整原始截图。未使用设计稿替代实际界面。
以下是独立原生模拟器测试工程的真实画面,生产CrabInput组件逐字节复制,仅增加自动验收入口。圆/方普通及QuickApp四配置96状态/截图通过,26个字母等宽等高(像素取整误差最多1px),模式及底部按钮均检查边界;每配置实际点击大小写按钮输入qQq。完整日志、几何、96张图及源码摘要见本版验收ZIP。
## Android 应用内组件
UI_PREVIEW.png为本版圆屏英文画面。不是RTOS真机或最终发布包的全套前台端到端验收。
![演示首页](screenshots/android-home.png)
![16 个单字一页](screenshots/android-candidates-16.png)
![完整 QWERTY](screenshots/android-qwerty.png)
![14 键](screenshots/android-fourteen.png)
![词组自适应](screenshots/android-phrases.png)
![选词回填](screenshots/android-result.png)
![独立应用接入](screenshots/android-host.png)
![圆屏小写](1.2.5/round-bluewhite-en-lower.png)
![圆屏大写](1.2.5/round-bluewhite-en-upper.png)
![方屏全拼](1.2.5/rect-bluewhite-py-lower.png)
![黑绿英文](1.2.5/round-blackgreen-en-lower.png)
![圆屏光标](1.2.5/round-bluewhite-middle.png)
![方屏光标](1.2.5/rect-bluewhite-middle.png)
## Glyphix RTOS
性能对比见[PERFORMANCE.json](1.2.5/PERFORMANCE.json),是Windows Node/V8结果,不能当作MCU/手表内存或帧率。
Android本版只有后台离屏原生组件回归,未提供新的前台截图。
![圆屏候选](screenshots/rtos-round-candidates.png)
![圆屏 14 键](screenshots/rtos-round-14-key.png)
![圆屏词组](screenshots/rtos-round-phrase-candidates.png)
![方屏候选](screenshots/rtos-rect-candidates.png)
## 历史截图记录
# 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)
二进制
查看文件
二进制文件未显示。

之前

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

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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