fix(输入法): 修复光标间隙并扩大26键及明确大小写切换

这个提交包含在:
sydxky 于 2026-11-14 19:37:32 +08:00 提交
1 父节点 b95e7caae7
当前提交 100757f684
35 个文件已更改
+675 -243

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

+10 -2
查看文件
@@ -2,7 +2,9 @@
**蟹蟹输入 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.3 / 10203`。基于 [CrabKeyboard](https://github.com/crabPangXie/crabKeyboard) 的 MIT 全量词库和拼音前缀消费方式,重写为 Glyphix `.ux` 页面;不依赖 HarmonyOS HML、Android 输入法服务、DOM 或网络。
独立应用 `com.sydxky.rtos.inputmethod`,版本 `1.2.4 / 10204`。基于 [CrabKeyboard](https://github.com/crabPangXie/crabKeyboard) 的 MIT 全量词库和拼音前缀消费方式,重写为 Glyphix `.ux` 页面;不依赖 HarmonyOS HML、Android 输入法服务、DOM 或网络。
1.2.4 将字体字符光标换成独立1像素细线,光标紧贴前后文字,保留真实空格与Unicode边界;扩大26键,逐排适配圆屏安全区域。英文模式按钮明确显示下一步“大写/小写”,切换后实际输入大小写一致。验证范围见[本版验收](./VALIDATION_1.2.4.md)。
1.2.3 新增未完成拼音补全、离线上下文续写和词频排序;查询期间保留候选,避免先清空再重画。验证范围见[本版验收](./VALIDATION_1.2.3.md)。
@@ -21,7 +23,7 @@
- 点击首页文字区或“开始输入”即打开满屏键盘。完成回填;取消保留原文。草稿需主动保存,仅保存在本应用。
- 默认九宫大按键。“26键”切到标准 QWERTY 全拼,`v` 表示 `ü`;“九宫”切回九宫。“14键”直接进入双字母模式。连续点击对应字母组,选择候选后只消费匹配部分,剩余编码继续查词。存在未选词的九宫或14键编码时禁止切换模式/完成,避免丢字或把编码当文字提交。
- 第一行显示三个候选。点击右侧下箭头按4×4自适应分页查看全部匹配候选,左右箭头翻页,不使用原项目扩展列表的 100 项截断。完整拼音、九宫和14键先匹配完整编码,再提供未完成拼音补全和较短前缀;通过本地 jieba 0.42.1 词频及当前光标前的中文上下文排序,学习偏好可优先排序。
- 支持英文大小写、数字、完整可打印 ASCII 标点与常用中文标点。符号分两组大按键,通过“下一组符号”切换。网址额外提供 `https://`、`.com`。
- 点“abc”进入英文;再点“大写”切换大写,点“小写”恢复小写,字母键标签与输入字符同步。支持数字、完整可打印 ASCII 标点与常用中文标点。符号分两组大按键,通过“下一组符号”切换。网址额外提供 `https://`、`.com`。
- 点击文本预览或铅笔图标移动光标、换行、上屏全拼、清空全文。Unicode 字符按码点计数和删除,避免拆坏 emoji。清空全文需第二次点击确认;长按退格图标连续删除,松开/隐藏/销毁停止。勾号完成,叉号取消,键盘图标返回输入。
- 学习默认关闭,可开启或清除,仅保存最多 100 组、每组三个已选择的真实候选,不记录整段正文。私密输入始终遮盖、不学习、不保存;切到其他应用时清空私密编辑状态。
@@ -71,3 +73,9 @@ python .\tests\visual.py --work '<另一个临时目录>' --output '<原生证
初期两位编码分片曾触发原生模拟器内存回收,已改为四位编码分片。验收保持既有 `--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.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真机、固件接入、长期性能/功耗和商店审核未验收。
@@ -49,6 +49,16 @@ def from94(value):
return number
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={}
@@ -82,8 +92,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():
@@ -101,7 +111,9 @@ def convert(source, target):
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),
+133 -20
查看文件
@@ -1,26 +1,139 @@
// Copy the canonical keyboard into another Glyphix sandbox; no cross-app data.
// The canonical component is maintained here; hosts consume generated copies.
import fs from 'node:fs'
import 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))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','common/ime-theme.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')
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)
@@ -55,11 +55,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
@@ -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()
}
@@ -9,6 +9,7 @@ export function themeStyles(value) {
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+';',
@@ -0,0 +1,162 @@
<template>
<div class="ime-surface" :style="themeStyles.surface">
<div class="ime" :style="themeStyles.surface">
<div class="top"><button id="ime-cancel" class="cancel icon-button" :style="themeStyles.icon" on:click="cancel"><image class="icon" src="/assets/ime-icons/close-line.png" /></button><p class="title" :style="themeStyles.muted">{{titleLabel}}</p><p class="count" :style="themeStyles.dim">{{count}} 字</p></div>
<button id="ime-preview" class="preview" :style="themeStyles.card" on:click="showTools"><p if="count" id="ime-preview-before" class="preview-text" :style="themeStyles.ink">{{previewBefore}}</p><div if="count" id="ime-caret" class="preview-caret" :style="themeStyles.caret"></div><p if="count" id="ime-preview-after" class="preview-text" :style="themeStyles.ink">{{previewAfter}}</p><p if="!count" class="preview-text" :style="themeStyles.ink">输入文字…</p></button>
<div if="panel !== 'candidates'" id="ime-composition" class="composition"><p class="spelling" :style="themeStyles.accent">{{compositionDisplay || modeLabel}}</p><p class="hint" :style="themeStyles.dim">{{hint}}</p></div>
<div if="panel === 'keys'" class="body">
<div if="mode === 't9' || mode === 'py' || mode === '14' || mode === 'sym' || inputType === 'url'" id="ime-candidate-strip" class="candidates" :style="themeStyles.raised"><button for="item in bar" class="candidate" :style="themeStyles.plain" on:click="choose(item)">{{item.word}}</button><button if="!composition &amp;&amp; inputType === 'url'" class="candidate" :style="themeStyles.plain" on:click="insert('https://')">https://</button><button if="!composition &amp;&amp; inputType === 'url'" class="candidate" :style="themeStyles.plain" on:click="insert('.com')">.com</button><button if="mode === 'sym'" class="candidate" :style="themeStyles.plain" on:click="symbols">下一组符号 {{symbolPage+1}}/2</button><p if="!total &amp;&amp; !busy &amp;&amp; mode !== 'sym' &amp;&amp; inputType !== 'url'" class="candidate-placeholder" :style="themeStyles.dim">{{secret ? '私密输入' : '选择候选词'}}</p><button if="mode !== 'sym' &amp;&amp; total" id="ime-expand" class="more icon-button" :style="themeStyles.icon" on:click="expand"><image class="icon" src="/assets/ime-icons/arrow-down-s-line.png" /></button></div>
<div if="mode === 'py' || mode === 'en'" id="ime-keypad" class="keypad" :style="keypadStyle">
<div class="keyrow qwerty-first" :style="layoutRows[0].style"><button for="key in keyRows[0]" :id="'ime-key-' + key.value.toLowerCase()" class="key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
<div class="keyrow qwerty-middle"><button for="key in keyRows[1]" :id="'ime-key-' + key.value.toLowerCase()" class="key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
<div class="keyrow qwerty-last" :style="layoutRows[2].style"><button for="key in keyRows[2]" :id="'ime-key-' + key.value.toLowerCase()" class="key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
</div>
<div if="mode !== 'py' &amp;&amp; mode !== 'en'" id="ime-keypad" class="keypad">
<div for="row in layoutRows" class="keyrow" :style="row.style"><button for="key in row.keys" class="key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p><p if="key.sub" class="key-sub" :style="themeStyles.dim">{{key.sub}}</p></button></div>
</div>
<div class="modes"><button id="ime-chinese" :style="themeStyles.plain" on:click="chinese">{{mode === 't9' ? '26键' : '九宫'}}</button><button id="ime-14" :style="mode === '14' ? themeStyles.accent : themeStyles.muted" on:click="fourteen">14键</button><button id="ime-english" :style="themeStyles.card" on:click="english">{{mode === 'en' ? (upper ? '小写' : '大写') : 'abc'}}</button><button :style="themeStyles.card" on:click="numbers">123</button><button :style="themeStyles.card" on:click="symbols">符号</button><button id="ime-tools" class="icon-button" :style="themeStyles.icon" on:click="showTools"><image class="icon small-icon" src="/assets/ime-icons/edit-2-line.png" /></button></div>
<div class="bottom"><button id="ime-space" :style="themeStyles.card" on:click="space">空格</button><button id="ime-delete" class="delete icon-button" :style="themeStyles.icon" on:touchstart="startDelete" on:touchend="endDelete"><image class="icon" src="/assets/ime-icons/delete-back-2-line.png" /></button><button id="ime-finish" class="done icon-button" :style="themeStyles.primary" on:click="finish"><image class="icon" src="/assets/ime-icons/check-line.png" /></button></div>
</div>
<div if="panel === 'candidates'" class="body expanded">
<p class="page-info" :style="themeStyles.dim">完整候选 · {{total}} 项 · {{candidatePage + 1}}/{{pageCount}}</p>
<div class="full-list"><div for="row in candidateRows" class="candidate-grid-row" :style="row.style"><button for="entry in row.items" class="full-candidate" :style="entry.style" on:click="choose(entry.item)"><p class="word" :style="entry.labelStyle">{{entry.item.word}}</p></button></div><p if="total === 0" class="empty" :style="themeStyles.dim">{{busy ? '正在查词…' : '继续输入后查看候选'}}</p></div>
<div class="pager"><button id="ime-page-prev" class="icon-button" :style="themeStyles.icon" on:click="previousPage"><image class="icon" src="/assets/ime-icons/arrow-left-s-line.png" /></button><button id="ime-page-next" class="icon-button" :style="themeStyles.icon" on:click="nextPage"><image class="icon" src="/assets/ime-icons/arrow-right-s-line.png" /></button></div>
<div class="bottom"><button id="ime-back" class="icon-button" :style="themeStyles.icon" on:click="closePanel"><image class="icon" src="/assets/ime-icons/keyboard-box-line.png" /></button><button id="ime-delete" class="delete icon-button" :style="themeStyles.icon" on:click="backspace"><image class="icon" src="/assets/ime-icons/delete-back-2-line.png" /></button><button id="ime-finish" class="done icon-button" :style="themeStyles.primary" on:click="finish"><image class="icon" src="/assets/ime-icons/check-line.png" /></button></div>
</div>
<div if="panel === 'tools'" class="body tools">
<p class="page-info" :style="themeStyles.dim">{{secret ? '私密输入 · 不学习、不保存' : '光标编辑 · 输入仅保留在本机'}}</p>
<div class="toolrow"><button :style="themeStyles.card" on:click="cursorLeft">← 左移</button><button :style="themeStyles.card" on:click="cursorRight">右移 →</button></div>
<div class="toolrow"><button :style="themeStyles.card" on:click="cursorStart">到开头</button><button :style="themeStyles.card" on:click="cursorEnd">到末尾</button></div>
<div class="toolrow"><button :style="themeStyles.card" on:click="newline">换行</button><button :style="themeStyles.card" on:click="rawInput">拼音上屏</button></div>
<div class="toolrow"><button :style="themeStyles.card" on:click="toggleLearning">{{learning ? '学习:开' : '学习:关'}}</button><button :style="themeStyles.card" on:click="clearPreferences">清除学习</button></div>
<div class="toolrow"><button class="danger" :style="themeStyles.danger" on:click="clearText">{{clearArmed ? '确认清空' : '清空全文'}}</button><button :style="themeStyles.card" on:click="backspace">删除一字</button></div>
<div class="bottom"><button id="ime-back" class="icon-button" :style="themeStyles.icon" on:click="closePanel"><image class="icon" src="/assets/ime-icons/keyboard-box-line.png" /></button><button id="ime-finish" class="done icon-button" :style="themeStyles.primary" on:click="finish"><image class="icon" src="/assets/ime-icons/check-line.png" /></button></div>
</div>
</div>
</div>
</template>
<style>
.ime-surface{display:flex;flex-direction:column;width:100%;height:100%;background-color:#ECEEF2}
.ime{display:flex;flex-direction:column;align-items:stretch;width:100%;height:100%;padding:14px 10px;background-color:#ECEEF2;color:#20232B}
p{margin:0;color:#20232B;font-size:.62rem}button{padding:0;margin:3px;border-radius:12px;color:#20232B;background-color:#FFFFFF;font-size:.65rem;flex-shrink:0}button:active{background-color:#D7E5FF}
.icon-button{display:flex;align-items:center;justify-content:center}.icon{width:24px;height:24px;object-fit:contain}.small-icon{width:20px;height:20px}
.top,.composition,.candidates,.keyrow,.modes,.bottom,.pager,.toolrow{display:flex;flex-direction:row;align-items:center;justify-content:center;flex-shrink:0}
.top{height:27px}.cancel{width:44px;height:27px;background-color:transparent;color:#2867D9;font-size:.53rem}.title{flex:1;text-align:center;font-size:.5rem;color:#737984}.count{width:44px;font-size:.4rem;text-align:right;color:#9297A1}
.preview{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;height:43px;margin:7px 7px 3px;padding:0 14px;background-color:#FFFFFF;text-align:left;font-size:.82rem;color:#20232B;border-radius:13px}.preview-text{margin:0;padding:0;font-size:.82rem;flex-shrink:0}.preview-caret{width:1px;height:24px;margin:0;padding:0;flex-shrink:0}
.composition{height:24px;padding:0 12px}.spelling{flex:1;color:#2867D9;font-size:.55rem}.hint{color:#8B909A;font-size:.38rem;text-align:right}
.body,.keypad,.full-list{display:flex;flex-direction:column;align-items:stretch;min-height:0}.body{flex:1;justify-content:flex-start}.keypad{flex-shrink:0}
.candidates{height:42px;margin:0 4px 4px;background-color:#F8F9FB;border-radius:12px}.candidate{flex:1;min-width:45px;height:36px;background-color:transparent;color:#20232B;font-size:.78rem}.candidate-placeholder{flex:1;text-align:center;color:#A4A9B2;font-size:.45rem}.more{width:46px;height:36px;background-color:transparent;font-size:.5rem;color:#2867D9}
.keyrow{align-items:stretch;align-self:center;width:100%}.key{display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;height:52px;min-width:0;background-color:#FFFFFF;border-radius:12px}.key-label{font-size:.88rem;color:#22252E}.key-sub{font-size:.35rem;color:#9DA2AD;margin-top:1px}
.qwerty-first,.qwerty-first-py{width:100%}.qwerty-middle{width:94%}.qwerty-last,.qwerty-last-py{width:84%}
.modes{height:36px;margin-top:3px}.modes button{flex:1;height:31px;background-color:transparent;font-size:.53rem;color:#636A77}
.bottom{height:41px;margin-top:2px}.bottom button{flex:1;height:37px;font-size:.6rem;background-color:#FFFFFF}.bottom .done{background-color:#2867D9;color:#FFFFFF}.bottom .delete{background-color:#D9DDE4;color:#515866}
.page-info{text-align:center;color:#8B919D;font-size:.45rem;margin:5px 0;flex-shrink:0}.full-list{flex:1;justify-content:flex-start}.candidate-grid-row{display:flex;flex-direction:row;align-self:flex-start;align-items:stretch;flex-shrink:0}.full-candidate{display:flex;align-items:center;justify-content:center;flex:1;min-width:0;margin:2px;padding:2px 4px;background-color:#FFFFFF;border-radius:10px}.word{width:100%;text-align:center;font-size:25px;color:#22252E}.empty{margin:15px 8px;color:#9299A5;text-align:center}
.pager{height:38px}.pager button{flex:1;height:34px;background-color:transparent;color:#2867D9;font-size:.55rem}.toolrow button{flex:1;height:40px;font-size:.57rem}.tools{justify-content:center}.tools .danger{color:#C85656}.tools .bottom{margin-top:8px}
@media (shape:circle){.ime{padding:30px 8px}.top{align-self:center;width:186px;height:24px}.cancel{height:24px}.preview{align-self:center;width:280px;height:32px;margin-top:5px}.composition{align-self:center;width:300px;height:23px}.candidates{align-self:center;width:352px;height:38px}.candidate{height:34px}.more{height:34px}.keypad{align-self:center;width:368px}.key{height:49px}.modes{align-self:center;width:286px;height:33px}.modes button{height:29px}.bottom{align-self:center;width:200px;height:38px}.bottom button{height:34px}.full-list{align-self:center;width:340px}.pager{align-self:center;width:276px}.toolrow{align-self:center;width:300px}.toolrow button{height:34px}.tools .page-info{align-self:center;width:280px}.tools .bottom{margin-top:4px}}
@media (shape:circle){.qwerty-first{width:92%}.qwerty-first-py{width:96%}.qwerty-middle{width:100%}.qwerty-last{width:90%}.qwerty-last-py{width:80%}}
</style>
<script>
import file from '@system.file'
import storage from '@system.storage'
import {registerInput,unregisterInput} from '../../common/ime-lifecycle.js'
import {normalizeTheme, themeStyles as stylesForTheme} from '../../common/ime-theme.js'
import {createDictionary,chars,contextTail,candidatePages,edit,erase,previewParts,learn,sanitizePreferences,KEY14_GROUPS,display14} from '../../common/ime-engine.js'
const LETTERS=['qwertyuiop','asdfghjkl','zxcvbnm']
const SYMBOLS=['!"#$%&',"'()*+,",'-./:;<','=>?@[\\',']^_`{|','}~,。?!']
function keys(rows){return rows.map(row=>Array.from(row).map(value=>({label:value,value:value,sub:''})))}
export default {
data:{config:{},titleLabel:'蟹蟹输入',inputTheme:'bluewhite',themeStyles:stylesForTheme('bluewhite'),initial:'',title:'蟹蟹输入',inputType:'text',maxLength:2048,allowNewline:true,secret:false,revealSecret:false,text:'',cursor:0,composition:'',compositionDisplay:'',display:'',previewBefore:'',previewAfter:'',count:0,limit:2048,mode:'t9',modeLabel:'九宫拼音',upper:false,symbolPage:0,panel:'keys',keyRows:[],layoutRows:[],keyStyle:'',keypadStyle:'',labelStyle:'',bar:[],visibleCandidates:[],candidateRows:[],total:0,candidatePage:0,pageCount:1,busy:false,hint:'离线全量词库',learning:false,clearArmed:false},
dictionary:null,preferences:{},allCandidates:[],pages:[],prediction:false,active:true,life:null,requestId:0,lookupTimer:null,repeatTimer:null,repeatDelay:null,ended:false,original:'',
onInit(){
for(const key of ['initial','title','inputType','maxLength','allowNewline','inputTheme','secret','revealSecret'])if(this.config[key]!==undefined)this[key]=this.config[key]
registerInput(this)
this.titleLabel=chars(this.title).slice(0,4).join('')+(chars(this.title).length>4?'…':'');this.inputTheme=normalizeTheme(this.inputTheme);this.themeStyles=stylesForTheme(this.inputTheme)
this.life={active:true,id:0};this.secret=!!this.secret||this.inputType==='password'
this.limit=Math.floor(Math.max(1,Math.min(4096,Number(this.maxLength)||2048)))
this.text=chars(this.initial).slice(0,this.limit).join('');this.original=this.text;this.cursor=chars(this.text).length
try{this.preferences=sanitizePreferences(storage.get('crab.ime.preferences'));this.learning=!this.secret&&storage.get('crab.ime.learning')===true}catch(error){this.preferences={};this.learning=false}
this.dictionary=createDictionary(uri=>file.readText({uri}))
this.mode=this.inputType==='number'||this.inputType==='pin'?'num':this.inputType==='url'||this.inputType==='password'?'en':'t9'
this.renderKeys();this.updateDisplay()
},
onShow(){this.active=true;this.life.active=true;this.schedule()},
onHide(){this.active=false;this.life.active=false;this.invalidate();this.stopRepeat();if(this.secret)this.clearPrivate()},
clearPrivate(){this.active=false;this.life.active=false;this.invalidate();this.stopRepeat();this.text='';this.initial='';this.original='';this.composition='';this.allCandidates=[];this.bar=[];this.visibleCandidates=[];this.candidateRows=[];this.pages=[];this.total=0;this.config={};this.updateDisplay()},
onDestroy(){unregisterInput(this);this.active=false;if(this.life)this.life.active=false;this.invalidate();this.stopRepeat();if(this.dictionary)this.dictionary.clear();this.allCandidates=[];this.preferences={};this.text='';this.initial='';this.original='';this.config={}},
invalidate(){this.requestId++;this.life.id=this.requestId;if(this.lookupTimer!==null){clearTimeout(this.lookupTimer);this.lookupTimer=null}this.busy=false},
updateDisplay(){const count=chars(this.text).length,parts=previewParts(this.text,this.cursor,this.secret&&!this.revealSecret),display=parts.before+parts.after,composition=this.mode==='14'?display14(this.composition):this.composition;if(this.count!==count)this.count=count;if(this.display!==display)this.display=display;if(this.previewBefore!==parts.before)this.previewBefore=parts.before;if(this.previewAfter!==parts.after)this.previewAfter=parts.after;if(this.compositionDisplay!==composition)this.compositionDisplay=composition},
inputContext(){return this.secret?'':contextTail(chars(this.text).slice(0,this.cursor).join(''))},
clearCandidates(){this.allCandidates=[];this.bar=[];this.visibleCandidates=[];this.candidateRows=[];this.pages=[];this.total=0;this.pageCount=1},
renderKeys(){
if(this.mode==='t9')this.keyRows=[[{label:'标点',value:'symbols',sub:'1'},{label:'ABC',value:'2',sub:'2'},{label:'DEF',value:'3',sub:'3'}],[{label:'GHI',value:'4',sub:'4'},{label:'JKL',value:'5',sub:'5'},{label:'MNO',value:'6',sub:'6'}],[{label:'PQRS',value:'7',sub:'7'},{label:'TUV',value:'8',sub:'8'},{label:'WXYZ',value:'9',sub:'9'}]]
else if(this.mode==='num')this.keyRows=keys(['123','456','789','.0-'])
else if(this.mode==='sym')this.keyRows=keys(SYMBOLS.slice(this.symbolPage*3,this.symbolPage*3+3))
else if(this.mode==='14'){const all=KEY14_GROUPS.map((group,index)=>({label:group.toUpperCase(),value:'0123456789abcd'[index],sub:''}));this.keyRows=[all.slice(0,5),all.slice(5,10),all.slice(10)]}
else this.keyRows=keys(LETTERS.map(row=>this.upper&&this.mode==='en'?row.toUpperCase():row))
const qwerty=this.mode==='py'||this.mode==='en'
this.keyStyle='height:'+(qwerty?(this.mode==='en'?'58':'49'):this.keyRows.length<=3?'49':this.keyRows.length===4?'38':this.keyRows.length===5?'28':'22')+'px;'+this.themeStyles.card
this.keypadStyle=qwerty?'width:100%;':''
this.labelStyle=(qwerty?'font-size:22px;':this.mode==='14'?'font-size:24px;':'')+this.themeStyles.ink
this.layoutRows=this.keyRows.map((row,index)=>({keys:row,style:qwerty?(this.mode==='py'&&index!==1?'width:'+(index===0?'96':'80')+'%;':''):'width:'+(index===this.keyRows.length-1?'91':'100')+'%'}))
this.modeLabel={t9:'九宫拼音',py:'26键全拼 · v 代表 ü','14':'14键拼音',en:this.upper?'英文大写':'英文小写',num:'数字',sym:'符号 · '+(this.symbolPage+1)+'/2'}[this.mode]
this.compositionDisplay=this.mode==='14'?display14(this.composition):this.composition
},
setMode(mode){this.stopRepeat();if(this.composition&&mode!==this.mode){if(this.mode==='t9'||mode==='t9'||this.mode==='14'||mode==='14'){this.hint='请先选择候选词再切换';return}if(mode!=='py'&&!this.rawInput())return}this.mode=mode;this.panel='keys';this.renderKeys();this.schedule()},
chinese(){this.setMode(this.mode==='t9'?'py':'t9')},fourteen(){this.setMode('14')},english(){if(this.mode==='en')this.upper=!this.upper;this.setMode('en')},numbers(){this.setMode('num')},symbols(){if(this.mode==='sym'){this.symbolPage=1-this.symbolPage;this.renderKeys()}else{this.symbolPage=0;this.setMode('sym')}},
press(value){this.clearArmed=false;if(value==='symbols'){this.symbols();return}if(this.mode==='t9'||this.mode==='py'||this.mode==='14'){if(this.composition.length>=32){this.hint='每段最多 32 位';return}this.composition+=value.toLowerCase();this.schedule()}else this.insert(this.mode==='en'?(this.upper?String(value).toUpperCase():String(value).toLowerCase()):value)},
insert(value){
let insertion=String(value)
if(this.inputType==='number')insertion=insertion.replace(/[^0-9.\-]/g,'')
if(this.inputType==='pin')insertion=insertion.replace(/[^0-9]/g,'')
if(this.inputType==='url')insertion=insertion.replace(/\s/g,'')
if(!this.allowNewline)insertion=insertion.replace(/[\r\n]/g,'')
const next=edit(this.text,this.cursor,insertion,this.limit);this.text=next.text;this.cursor=next.cursor;this.updateDisplay();this.hint=next.limited?'已达字数上限':'离线输入'
return !next.limited
},
schedule(){
this.compositionDisplay=this.mode==='14'?display14(this.composition):this.composition
this.invalidate();this.candidatePage=0
const chinese=this.mode==='t9'||this.mode==='py'||this.mode==='14',prediction=!this.composition
if(!this.active||!chinese||(!this.composition&&!this.inputContext())){this.clearCandidates();this.prediction=false;return}
if(prediction!==this.prediction)this.clearCandidates()
this.prediction=prediction
const id=this.requestId;this.busy=true;if(!this.bar.length)this.hint=prediction?'联想…':'正在查词…'
this.lookupTimer=setTimeout(()=>{this.lookupTimer=null;this.query(id)},45)
},
async query(id){
const raw=this.composition,life=this.life,context=this.inputContext()
try{const result=raw?await this.dictionary.lookup(raw,this.mode==='14'?'14':this.mode==='t9',this.learning?this.preferences:{},context):await this.dictionary.predict(context)
if(!life.active||id!==life.id)return
this.prediction=!raw;this.allCandidates=result;this.total=result.length;this.bar=result.slice(0,3);this.pages=candidatePages(result);this.pageCount=Math.max(1,this.pages.length);this.candidatePage=0;this.pageCandidates();this.hint=result.length?(!raw?'联想 · ':'')+result.length+' 个候选':raw?'暂无匹配 · 可退格修改':'离线输入'
}catch(error){if(life.active&&id===life.id){this.clearCandidates();this.hint='词库暂不可用,可切换英文输入'}}
finally{if(life.active&&id===life.id)this.busy=false}
},
pageCandidates(){const rows=this.pages[this.candidatePage]||[];this.visibleCandidates=[];this.candidateRows=rows.map(row=>({style:'height:'+(row.units*43)+'px;width:'+(row.slots*25)+'%',items:row.items.map(entry=>{this.visibleCandidates.push(entry.item);return {item:entry.item,style:'flex:'+entry.span+';height:'+(row.units*43-4)+'px;'+this.themeStyles.card,labelStyle:'font-size:'+(entry.span===1?25:entry.span===2?23:20)+'px;'+this.themeStyles.ink}})}))},
choose(item){
if(this.busy||(!this.composition&&(!this.prediction||item.consume!==0))||!this.allCandidates.some(x=>x.word===item.word&&x.pinyin===item.pinyin&&x.consume===item.consume))return
if(chars(this.text).length+chars(item.word).length>this.limit){this.hint='剩余空间不足,未截断候选词';return}
const raw=this.composition;this.insert(item.word)
if(raw&&this.learning&&!this.secret){try{this.preferences=learn(this.preferences,this.mode==='14'?'k14:'+raw:raw,item);storage.set('crab.ime.preferences',this.preferences)}catch(error){this.learning=false}}
this.composition=raw.slice(item.consume);this.panel='keys';this.schedule()
},
expand(){this.panel='candidates';this.pageCandidates()},previousPage(){this.candidatePage=Math.max(0,this.candidatePage-1);this.pageCandidates()},nextPage(){this.candidatePage=Math.min(this.pageCount-1,this.candidatePage+1);this.pageCandidates()},
backspace(){this.clearArmed=false;if(this.composition){this.composition=this.composition.slice(0,-1);this.schedule()}else{const next=erase(this.text,this.cursor);this.text=next.text;this.cursor=next.cursor;this.updateDisplay();this.schedule()}},
startDelete(){this.stopRepeat();this.backspace();this.repeatDelay=setTimeout(()=>{this.repeatDelay=null;this.repeatTimer=setInterval(()=>this.backspace(),120)},450)},
endDelete(){this.stopRepeat()},
stopRepeat(){if(this.repeatTimer!==null){clearInterval(this.repeatTimer);this.repeatTimer=null}if(this.repeatDelay!==null){clearTimeout(this.repeatDelay);this.repeatDelay=null}},
space(){if(this.composition){if(!this.busy&&this.allCandidates.length)this.choose(this.allCandidates[0]);else this.hint='请等待候选或修改拼音'}else{this.insert(' ');this.schedule()}},
rawInput(){if((this.mode==='t9'||this.mode==='14')&&this.composition){this.hint='编码请先选择候选';return false}if(this.composition){if(!this.insert(this.composition))return false;this.composition='';this.schedule()}return true},
showTools(){this.stopRepeat();this.panel='tools';this.clearArmed=false},closePanel(){this.panel='keys';this.clearArmed=false},
cursorLeft(){this.cursor=Math.max(0,this.cursor-1);this.updateDisplay();this.schedule()},cursorRight(){this.cursor=Math.min(chars(this.text).length,this.cursor+1);this.updateDisplay();this.schedule()},cursorStart(){this.cursor=0;this.updateDisplay();this.schedule()},cursorEnd(){this.cursor=chars(this.text).length;this.updateDisplay();this.schedule()},
newline(){if(this.allowNewline&&this.inputType!=='url'&&this.inputType!=='number'&&!this.secret){this.insert('\n');this.schedule()}else this.hint='此输入项不支持换行'},
clearText(){if(!this.clearArmed){this.clearArmed=true;this.hint='再次点击确认清空';return}this.text='';this.cursor=0;this.composition='';this.clearArmed=false;this.schedule();this.updateDisplay()},
toggleLearning(){if(this.secret){this.hint='私密输入不记录学习';return}try{storage.set('crab.ime.learning',!this.learning);this.learning=!this.learning;this.schedule()}catch(error){this.learning=false;this.hint='本机存储不可用,学习已关闭'}},
clearPreferences(){try{storage.delete('crab.ime.preferences');this.preferences={};this.schedule();this.hint='本应用学习记录已清除'}catch(error){this.hint='学习清除失败,请稍后重试'}},
finish(){if(this.ended||!this.active)return;this.stopRepeat();if(this.composition){this.hint='请先选择候选词或在编辑中上屏拼音';this.panel='keys';return}const result={accepted:true,text:this.text,session:this.config.session};this.ended=true;this.clearPrivate();this.$emit('complete',result)},
cancel(){if(this.ended)return;const result={accepted:false,session:this.config.session};this.ended=true;this.clearPrivate();this.$emit('cancel',result)}
}
</script>
+16 -150
查看文件
@@ -1,152 +1,18 @@
<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">{{count ? display : '输入文字…'}}</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 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 :style="themeStyles.card" on:click="english">{{upper ? 'ABC' : '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{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: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}
.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}}
</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 {normalizeTheme, themeStyles as stylesForTheme} from '../common/ime-theme.js'
import {createDictionary,chars,contextTail,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:{titleLabel:'蟹蟹输入',inputTheme:'bluewhite',themeStyles:stylesForTheme('bluewhite'),initial:'',title:'蟹蟹输入',inputType:'text',maxLength:2048,allowNewline:true,secret:false,revealSecret: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:[],prediction:false,active:true,life:null,requestId:0,lookupTimer:null,repeatTimer:null,repeatDelay:null,result:null,original:'',
onInit(){
this.titleLabel=chars(this.title).slice(0,4).join('')+(chars(this.title).length>4?'…':'');this.inputTheme=normalizeTheme(this.inputTheme);this.themeStyles=stylesForTheme(this.inputTheme)
this.life={active:true,id:0};this.secret=!!this.secret||this.inputType==='password'
this.limit=Math.floor(Math.max(1,Math.min(4096,Number(this.maxLength)||2048)))
this.text=chars(this.initial).slice(0,this.limit).join('');this.original=this.text;this.cursor=chars(this.text).length
try{this.preferences=sanitizePreferences(storage.get('crab.ime.preferences'));this.learning=!this.secret&&storage.get('crab.ime.learning')===true}catch(error){this.preferences={};this.learning=false}
this.dictionary=createDictionary(uri=>file.readText({uri}))
this.mode=this.inputType==='number'||this.inputType==='pin'?'num':this.inputType==='url'||this.inputType==='password'?'en':'t9'
this.renderKeys();this.updateDisplay()
},
onShow(){this.active=true;this.life.active=true;this.schedule()},
onHide(){this.active=false;this.life.active=false;this.invalidate();this.stopRepeat();if(this.secret)this.clearPrivate()},
clearPrivate(){this.active=false;this.life.active=false;this.invalidate();this.stopRepeat();this.text='';this.initial='';this.original='';this.composition='';this.allCandidates=[];this.bar=[];this.visibleCandidates=[];this.candidateRows=[];this.pages=[];this.total=0;this.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(){const count=chars(this.text).length,display=preview(this.text,this.cursor,this.secret&&!this.revealSecret),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.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))
this.keyStyle='height:'+(this.keyRows.length<=3?'49':this.keyRows.length===4?'38':this.keyRows.length===5?'28':'22')+'px;'+this.themeStyles.card
const qwerty=this.mode==='py'||this.mode==='en'
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:'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.candidatePage=0
const chinese=this.mode==='t9'||this.mode==='py'||this.mode==='14',prediction=!this.composition
if(!this.active||!chinese||(!this.composition&&!this.inputContext())){this.clearCandidates();this.prediction=false;return}
if(prediction!==this.prediction)this.clearCandidates()
this.prediction=prediction
const id=this.requestId;this.busy=true;if(!this.bar.length)this.hint=prediction?'联想…':'正在查词…'
this.lookupTimer=setTimeout(()=>{this.lookupTimer=null;this.query(id)},45)
},
async query(id){
const raw=this.composition,life=this.life,context=this.inputContext()
try{const result=raw?await this.dictionary.lookup(raw,this.mode==='14'?'14':this.mode==='t9',this.learning?this.preferences:{},context):await this.dictionary.predict(context)
if(!life.active||id!==life.id)return
this.prediction=!raw;this.allCandidates=result;this.total=result.length;this.bar=result.slice(0,3);this.pages=candidatePages(result);this.pageCount=Math.max(1,this.pages.length);this.candidatePage=0;this.pageCandidates();this.hint=result.length?(!raw?'联想 · ':'')+result.length+' 个候选':raw?'暂无匹配 · 可退格修改':'离线输入'
}catch(error){if(life.active&&id===life.id){this.clearCandidates();this.hint='词库暂不可用,可切换英文输入'}}
finally{if(life.active&&id===life.id)this.busy=false}
},
pageCandidates(){const rows=this.pages[this.candidatePage]||[];this.visibleCandidates=[];this.candidateRows=rows.map(row=>({style:'height:'+(row.units*43)+'px;width:'+(row.slots*25)+'%',items:row.items.map(entry=>{this.visibleCandidates.push(entry.item);return {item:entry.item,style:'flex:'+entry.span+';height:'+(row.units*43-4)+'px;'+this.themeStyles.card,labelStyle:'font-size:'+(entry.span===1?25:entry.span===2?23:20)+'px;'+this.themeStyles.ink}})}))},
choose(item){
if(this.busy||(!this.composition&&(!this.prediction||item.consume!==0))||!this.allCandidates.some(x=>x.word===item.word&&x.pinyin===item.pinyin&&x.consume===item.consume))return
if(chars(this.text).length+chars(item.word).length>this.limit){this.hint='剩余空间不足,未截断候选词';return}
const raw=this.composition;this.insert(item.word)
if(raw&&this.learning&&!this.secret){try{this.preferences=learn(this.preferences,this.mode==='14'?'k14:'+raw:raw,item);storage.set('crab.ime.preferences',this.preferences)}catch(error){this.learning=false}}
this.composition=raw.slice(item.consume);this.panel='keys';this.schedule()
},
expand(){this.panel='candidates';this.pageCandidates()},previousPage(){this.candidatePage=Math.max(0,this.candidatePage-1);this.pageCandidates()},nextPage(){this.candidatePage=Math.min(this.pageCount-1,this.candidatePage+1);this.pageCandidates()},
backspace(){this.clearArmed=false;if(this.composition){this.composition=this.composition.slice(0,-1);this.schedule()}else{const next=erase(this.text,this.cursor);this.text=next.text;this.cursor=next.cursor;this.updateDisplay();this.schedule()}},
startDelete(){this.stopRepeat();this.backspace();this.repeatDelay=setTimeout(()=>{this.repeatDelay=null;this.repeatTimer=setInterval(()=>this.backspace(),120)},450)},
endDelete(){this.stopRepeat()},
stopRepeat(){if(this.repeatTimer!==null){clearInterval(this.repeatTimer);this.repeatTimer=null}if(this.repeatDelay!==null){clearTimeout(this.repeatDelay);this.repeatDelay=null}},
space(){if(this.composition){if(!this.busy&&this.allCandidates.length)this.choose(this.allCandidates[0]);else this.hint='请等待候选或修改拼音'}else{this.insert(' ');this.schedule()}},
rawInput(){if((this.mode==='t9'||this.mode==='14')&&this.composition){this.hint='编码请先选择候选';return false}if(this.composition){if(!this.insert(this.composition))return false;this.composition='';this.schedule()}return true},
showTools(){this.stopRepeat();this.panel='tools';this.clearArmed=false},closePanel(){this.panel='keys';this.clearArmed=false},
cursorLeft(){this.cursor=Math.max(0,this.cursor-1);this.updateDisplay();this.schedule()},cursorRight(){this.cursor=Math.min(chars(this.text).length,this.cursor+1);this.updateDisplay();this.schedule()},cursorStart(){this.cursor=0;this.updateDisplay();this.schedule()},cursorEnd(){this.cursor=chars(this.text).length;this.updateDisplay();this.schedule()},
newline(){if(this.allowNewline&&this.inputType!=='url'&&this.inputType!=='number'&&!this.secret){this.insert('\n');this.schedule()}else this.hint='此输入项不支持换行'},
clearText(){if(!this.clearArmed){this.clearArmed=true;this.hint='再次点击确认清空';return}this.text='';this.cursor=0;this.composition='';this.clearArmed=false;this.schedule();this.updateDisplay()},
toggleLearning(){if(this.secret){this.hint='私密输入不记录学习';return}try{storage.set('crab.ime.learning',!this.learning);this.learning=!this.learning;this.schedule()}catch(error){this.learning=false;this.hint='本机存储不可用,学习已关闭'}},
clearPreferences(){try{storage.delete('crab.ime.preferences');this.preferences={};this.schedule();this.hint='本应用学习记录已清除'}catch(error){this.hint='学习清除失败,请稍后重试'}},
finish(){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>
@@ -2,8 +2,8 @@
"package": "com.sydxky.rtos.inputmethod",
"name": "蟹蟹输入",
"icon": "/assets/icon.png",
"versionName": "1.2.3",
"versionCode": 10203,
"versionName": "1.2.4",
"versionCode": 10204,
"features": [],
"router": {
"entry": "main",
@@ -3,15 +3,15 @@ import vm from 'node:vm'
import assert from 'node:assert/strict'
import * as engine from '../src/common/ime-engine.js'
import {normalizeTheme,themeStyles as stylesForTheme} from '../src/common/ime-theme.js'
const ux=fs.readFileSync(new URL('../src/keyboard/index.ux',import.meta.url),'utf8')
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,normalizeTheme,stylesForTheme,router:{close(p,r){closed.push(r)}},storage:{get:k=>store.get(k),set:(k,v)=>{if(storageFails)throw Error('storage full');store.set(k,v)},delete:k=>store.delete(k)},file:{readText:async({uri})=>fs.readFileSync(new URL('../src'+uri,import.meta.url),'utf8')},setTimeout:fn=>{const id=nextTimer++;timers.set(id,fn);return id},clearTimeout:id=>timers.delete(id),setInterval:fn=>{const id=nextTimer++;timers.set(id,fn);return id},clearInterval:id=>timers.delete(id)})
const p={...module.exports,...JSON.parse(JSON.stringify(module.exports.data)),...params};delete p.data;p.onInit();return p
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')
@@ -44,6 +44,8 @@ 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')
@@ -55,4 +57,7 @@ const beforeStale=p.text;p.choose(steadyBar[0]);check(p.text===beforeStale,'reta
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,107 @@
"""Native caret/QWERTY fixture using an owned emulator without Inspector."""
import argparse,hashlib,importlib.util,json,math,pathlib,shutil,subprocess,time
APP=pathlib.Path(__file__).resolve().parents[1]
spec=importlib.util.spec_from_file_location('native',APP/'tests/native.py')
native=importlib.util.module_from_spec(spec);spec.loader.exec_module(native)
emu=native.emu;emu.APPS=['inputmethod']
PROBE='''<template><div><p>输入组件原生验收</p></div></template>
<script>
import router from '@system.router'
import tc from '@system.test'
import {activeInputs} from '../common/ime-lifecycle.js'
import {themeStyles} from '../common/ime-theme.js'
export default {started:false,onShow(){if(this.started)return;this.started=true;setTimeout(()=>{
tc.testcase('preview-qwerty',async()=>{
router.push({uri:'/keyboard',params:{initial:'不'}});await tc.wait(.5);
var page=router.getPages().slice(-1)[0],p=activeInputs().filter(input=>input.$page===page)[0];
function check(ok,label){if(!ok)throw new Error(label);console.log('PROBE_CHECK '+label)}
async function rect(id){return await p.$element(id).getBoundingClientRect()}
var cases=[['chinese','不',1,false],['phrase','你好',2,false],['ascii','watch',5,false],['mixed','ab你😀好',4,false],['start','你好',0,false],['middle','你好',1,false],['long','长😀'.repeat(30),30,false],['secret','私密😀',2,true]];
for(var theme of ['bluewhite','blackgreen']){
p.invalidate();p.inputTheme=theme;p.themeStyles=themeStyles(theme);
for(var item of cases){p.invalidate();p.mode='en';p.upper=false;p.secret=item[3];p.revealSecret=false;p.text=item[1];p.cursor=item[2];p.composition='';p.renderKeys();p.updateDisplay();await tc.wait(.15);
console.log('PROBE_CAPTURE '+JSON.stringify({name:theme+'-'+item[0],theme:theme,kind:'caret',before:await rect('ime-preview-before'),caret:await rect('ime-caret'),after:await rect('ime-preview-after'),preview:await rect('ime-preview')}));await tc.wait(1.1);}
for(var mode of ['py','en'])for(var upper of [false,true]){
p.invalidate();p.secret=false;p.text='';p.cursor=0;p.composition='';p.mode=mode;p.upper=upper;p.renderKeys();p.updateDisplay();await tc.wait(.15);
var alphabet='qwertyuiopasdfghjklzxcvbnm',keys=[];
for(var letter of alphabet)keys.push({key:letter,rect:await rect('ime-key-'+letter)});
console.log('PROBE_CAPTURE '+JSON.stringify({name:theme+'-'+mode+'-'+(upper?'upper':'lower'),theme:theme,kind:'qwerty',keys:keys}));await tc.wait(1.1);}
}
p.invalidate();p.secret=false;p.text='';p.cursor=0;p.composition='';p.mode='en';p.upper=false;p.renderKeys();p.updateDisplay();await tc.wait(.2);
await tc.getById('ime-key-q').click();await tc.getById('ime-english').click();await tc.wait(.15);check(p.upper,'uppercase enabled');await tc.getById('ime-key-q').click();await tc.getById('ime-english').click();await tc.wait(.15);check(!p.upper,'lowercase restored');await tc.getById('ime-key-q').click();check(p.text==='qQq','actual native keys commit qQq');
console.log('PROBE_DONE');});tc.run('preview-qwerty');},300)}}
</script>'''
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--work',type=pathlib.Path,required=True);parser.add_argument('--output',type=pathlib.Path,required=True);parser.add_argument('--profiles',nargs='+',default=emu.PROFILES)
args=parser.parse_args();args.output.mkdir(parents=True,exist_ok=True);fixture=args.work/'fixture';fixture.mkdir(parents=True,exist_ok=True)
shutil.copytree(APP/'src',fixture/'src',dirs_exist_ok=True);shutil.copy2(APP/'glyphix.config.js',fixture/'glyphix.config.js')
metadata=json.loads((fixture/'src/manifest.json').read_text(encoding='utf-8'));metadata['router']['entry']='native-probe';metadata['router']['pages']['native-probe']={'component':'index'}
(fixture/'src/manifest.json').write_text(json.dumps(metadata,ensure_ascii=False),encoding='utf-8')
(fixture/'src/native-probe').mkdir(exist_ok=True);(fixture/'src/native-probe/index.ux').write_text(PROBE,encoding='utf-8')
source_hash=hashlib.sha256((APP/'src/components/CrabInput/index.ux').read_bytes()).hexdigest()
assert (fixture/'src/components/CrabInput/index.ux').read_bytes()==(APP/'src/components/CrabInput/index.ux').read_bytes()
shell=shutil.which('pwsh')or'powershell';stable=pathlib.Path(r'C:\Users\herry\AppData\Local\Programs\PowerShell\7\pwsh.exe')
if stable.exists():shell=str(stable)
with (args.output/'fixture-build.log').open('w',encoding='utf-8')as log:
result=subprocess.run([shell,'-NoProfile','-File',str(APP.parent/'build-rtos-game.ps1'),'-ProjectRoot',str(fixture)],stdout=log,stderr=subprocess.STDOUT)
assert result.returncode==0,'See fixture-build.log'
report={'method':'owned native console; production keyboard copied byte-for-byte into an auto-running fixture','production_keyboard_sha256':source_hash,'production_keyboard_path':'src/components/CrabInput/index.ux','production_route_sha256':hashlib.sha256((APP/'src/keyboard/index.ux').read_bytes()).hexdigest(),'inspector_used':False,'cases':[],'profiles':[],'passed':False}
def rectangle(value):
if isinstance(value,dict)and'rect'in value:value=value['rect']
return [value.get('left',value.get('x',0)),value.get('top',value.get('y',0)),value.get('width',value.get('w',0)),value.get('height',value.get('h',0))]
for profile in args.profiles:
image,artifacts=emu.prepare(profile,args.work)
device='generic-watch-'+('466x466'if profile.startswith('round')else'390x450');package=metadata['package']
binary=fixture/'.glyphix-work'/('dist/'+device+'-quickapp/'+package+'/bundle.pkg'if profile.endswith('-quickapp')else'image/'+device+'/apps/'+package+'.pkg')
shutil.copy2(binary,image/'apps'/(package+'.pkg'))
command=[str(emu.ROOT/'temp/glyphix-v0.7.2-windows-x64/bin/glyphix-emu.exe'),'--config','pkgs.db','--target',package]
if profile.startswith('round'):command+=['--width','624','--height','624','--dpi','326','--font-size','32','--round','--surface','surface/surface-466x466.json']
else:command+=['--width','390','--height','450','--dpi','321','--font-size','29']
command+=['--memory-profile','8192','--font-map','font-faces','--font-family','sans-serif']
log_path=args.output/(profile+'-console.log');seen=set();finished=False;captures=0
with log_path.open('w',encoding='utf-8')as log:
process=subprocess.Popen(command,cwd=image,stdin=subprocess.PIPE,stdout=log,stderr=subprocess.STDOUT,creationflags=subprocess.CREATE_NO_WINDOW)
try:
deadline=time.monotonic()+100
while time.monotonic()<deadline and process.poll()is None:
lines=log_path.read_text(encoding='utf-8',errors='replace').splitlines()
for line in lines:
if 'PROBE_CAPTURE 'in line:
try:state=json.JSONDecoder().raw_decode(line.split('PROBE_CAPTURE ',1)[1].lstrip())[0]
except json.JSONDecodeError:continue
if state['name']in seen:continue
seen.add(state['name']);state['profile']=profile
# The vendor console accepts relative screenshot names only.
filename=profile+'-'+state['name']+'.png';process.stdin.write(('screenshot '+filename+'\n').encode());process.stdin.flush()
if state['kind']=='caret':
before,caret,after,preview=[rectangle(state[key])for key in ['before','caret','after','preview']]
state.update(left_gap_px=caret[0]-before[0]-before[2],right_gap_px=after[0]-caret[0]-caret[2])
state['passed']=abs(state['left_gap_px'])<=1 and abs(state['right_gap_px'])<=1 and 0<caret[2]<=2 and preview[0]<=caret[0]and caret[0]+caret[2]<=preview[0]+preview[2]
else:
width=466 if profile.startswith('round')else 390;height=466 if profile.startswith('round')else 450;failures=[]
for key in state['keys']:
x,y,w,h=rectangle(key['rect'])
if w<=0 or h<=0 or x<0 or y<0 or x+w>width or y+h>height:failures.append(key['key'])
if profile.startswith('round')and max(math.hypot(tx-233,ty-233)for tx in [x,x+w]for ty in [y,y+h])>231:failures.append(key['key'])
state.update(failures=failures,passed=len(state['keys'])==26 and not failures)
captures+=1;report['cases'].append(state);native.save(args.output/'caret-results.json',report);print(json.dumps(state,ensure_ascii=False),flush=True)
if 'PROBE_DONE'in line:finished=True
if finished:break
time.sleep(.05)
assert finished and captures==24,'Native fixture did not finish; see '+str(log_path)
assert 'terminated:'not in log_path.read_text(encoding='utf-8',errors='replace'),'Native assertion failed'
for state in report['cases']:
if state['profile']!=profile:continue
filename=profile+'-'+state['name']+'.png';snapshot=image/filename
assert snapshot.is_file() and snapshot.read_bytes().startswith(b'\x89PNG\r\n\x1a\n'),'Screenshot missing: '+filename
shutil.copy2(snapshot,args.output/filename);state['screenshot']=filename
report['profiles'].append({'profile':profile,'native_case_switch_passed':True,'captures':captures,'fixture_binary_sha256':hashlib.sha256(binary.read_bytes()).hexdigest()})
finally:
if process.poll()is None:process.terminate()
process.wait(timeout=10);process.stdin.close()
report['passed']=all(row['passed']for row in report['cases']);native.save(args.output/'caret-results.json',report)
assert report['passed'],'See caret-results.json'
if __name__=='__main__':main()
@@ -2,7 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import 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,6 +55,12 @@ 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')
+9 -9
查看文件
@@ -45,29 +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,54 @@
import fs from 'node:fs'
import path from 'node:path'
import {fileURLToPath} from 'node:url'
import assert from 'node:assert/strict'
import {syncInput,checkInput,watchInput} from '../scripts/install.mjs'
const parent=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../.glyphix-work')
fs.mkdirSync(parent,{recursive:true})
const scratch=fs.mkdtempSync(path.join(parent,'sync-test-')),source=path.join(scratch,'source'),host=path.join(scratch,'host')
function write(root,name,body){const file=path.join(root,name);fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,body)}
function fixture(root){
write(root,'src/manifest.json',JSON.stringify({versionName:'fixture',router:{pages:{}}}))
for(const name of ['keyboard/index.ux','components/CrabInput/index.ux','assets/ime-icons/icon.png',...['engine','theme','lifecycle'].map(n=>'common/ime-'+n+'.js')])write(root,'src/'+name,'first')
write(root,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['2222']}));write(root,'src/assets/ime-dict/2222.json','{}')
for(const name of ['crabKeyboard','jieba','remix-icon'])write(root,'third_party/'+name+'/LICENSE','MIT')
write(root,'LICENSE','MIT');write(root,'NOTICE.md','third party notices')
}
let stop
try{
fixture(source);write(host,'src/manifest.json',JSON.stringify({router:{pages:{}}}));write(host,'src/assets/host-data.txt','preserve')
write(host,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['1111']}));write(host,'src/assets/ime-dict/1111.json','old format')
const first=syncInput(host,source);assert.equal(first.copied,13);assert.equal(first.removed,1);checkInput(host,source)
const timestamp=fs.statSync(path.join(host,'src/components/CrabInput/index.ux')).mtimeMs
assert.equal(syncInput(host,source).copied,0);assert.equal(fs.statSync(path.join(host,'src/components/CrabInput/index.ux')).mtimeMs,timestamp)
write(host,'src/common/ime-theme.js','stale');assert.throws(()=>checkInput(host,source),/differs/);assert.equal(syncInput(host,source).copied,1)
write(source,'src/common/ime-engine.js','second');assert.throws(()=>checkInput(host,source),/changed/)
write(host,'input-component.json',JSON.stringify({schema:1,integration:'component'}));syncInput(host,source)
assert.ok(!JSON.parse(fs.readFileSync(path.join(host,'src/manifest.json'),'utf8')).router.pages.keyboard)
assert.ok(!fs.existsSync(path.join(host,'src/keyboard/index.ux')))
write(source,'src/keyboard/index.ux','unused compatibility route update');checkInput(host,source)
const events=[];stop=watchInput(host,source,event=>events.push(event))
const initial=events.length
write(source,'src/components/CrabInput/index.ux','component update')
write(source,'src/common/ime-theme.js','theme update')
write(source,'src/assets/ime-icons/new-icon.png','new icon')
write(source,'src/common/ime-layout.js','new related module')
write(source,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['3333'],association_shards:['a0f'],completion_shards:['c01']}));write(source,'src/assets/ime-dict/3333.json','{"changed":true}');write(source,'src/assets/ime-dict/a0f.json','{}');write(source,'src/assets/ime-dict/c01.json','{}')
const deadline=Date.now()+8000
while(Date.now()<deadline){await new Promise(resolve=>setTimeout(resolve,50));try{checkInput(host,source);break}catch{}}
const current=checkInput(host,source)
assert.ok(fs.existsSync(path.join(host,'src/assets/ime-dict/a0f.json'))&&fs.existsSync(path.join(host,'src/assets/ime-dict/c01.json')),'prediction shards synchronize')
assert.notEqual(current.sourceHash,first.sourceHash);assert.ok(events.length>initial)
assert.equal(fs.readFileSync(path.join(host,'src/common/ime-layout.js'),'utf8'),'new related module')
assert.ok(!fs.existsSync(path.join(host,'src/assets/ime-dict/2222.json')))
assert.equal(fs.readFileSync(path.join(host,'src/assets/host-data.txt'),'utf8'),'preserve')
const receipt=JSON.parse(fs.readFileSync(path.join(host,'src/assets/crab-input-source.json'),'utf8'))
receipt.files['../outside']={};write(host,'src/assets/crab-input-source.json',JSON.stringify(receipt))
assert.throws(()=>syncInput(host,source),/Invalid managed/)
assert.ok(!fs.existsSync(path.join(host,'src/assets/crab-input-source.json')),'failed sync invalidates receipt')
console.log(JSON.stringify({suite:'input-sync',status:'passed',watchEvents:events.length,checks:['incremental','tamper rejection','source drift rejection','watch component engine theme icon dictionary update','obsolete shard removal','host isolation','invalid receipt rejection']}))
}finally{
if(stop)stop()
if(!scratch.startsWith(parent+path.sep+'sync-test-'))throw Error('Unexpected test directory')
fs.rmSync(scratch,{recursive:true,force:true})
}
+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')