perf(输入法): 降低查词与候选内存开销并放大等尺寸26键

这个提交包含在:
sydxky 于 2026-11-14 20:39:54 +08:00 提交
1 父节点 100757f684
当前提交 7cd1ab906c
33 个文件已更改
+513 -126

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

+4 -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.4 / 10204`。基于 [CrabKeyboard](https://github.com/crabPangXie/crabKeyboard) 的 MIT 全量词库和拼音前缀消费方式,重写为 Glyphix `.ux` 页面;不依赖 HarmonyOS HML、Android 输入法服务、DOM 或网络。
独立应用 `com.sydxky.rtos.inputmethod`,版本 `1.2.5 / 10205`。基于 [CrabKeyboard](https://github.com/crabPangXie/crabKeyboard) 的 MIT 全量词库和拼音前缀消费方式,重写为 Glyphix `.ux` 页面;不依赖 HarmonyOS HML、Android 输入法服务、DOM 或网络。
1.2.5 降低查词及候选开销:2,158 B运行索引,3个分片/48,000字符LRU,去掉整份词库复制,按需生成当前候选页,隐藏即释放缓存。26字母统一大小并放大,圆屏同步调整留白、候选栏和底部操作。详见[本版验收](./VALIDATION_1.2.5.md)。
1.2.4 将字体字符光标换成独立1像素细线,光标紧贴前后文字,保留真实空格与Unicode边界;扩大26键,逐排适配圆屏安全区域。英文模式按钮明确显示下一步“大写/小写”,切换后实际输入大小写一致。验证范围见[本版验收](./VALIDATION_1.2.4.md)。
@@ -68,7 +70,7 @@ python .\tests\visual.py --work '<另一个临时目录>' --output '<原生证
## 内存与词库
`convert-dictionary.py` 将原始 V5 trie 无损转换为 **6779** 个编码分片(九宫/全拼4194个、14键2585个),最大分片 **116,686** 字节。九宫/全拼使用四位前缀,14键使用三位前缀;短编码单独索引。解码缓存限制为四项且 JSON 文本合计不超过 90,000 字符,大分片不缓存。仅当前页最多16个候选进入展开网格,所有候选对象不放进响应式状态。转换器分别回读两种索引,验证与原始索引完全相同;不改写原始字典。
`convert-dictionary.py` 将原始 V5 trie 无损转换为 **6779** 个编码分片(九宫/全拼4194个、14键2585个),最大分片 **116,686** 字节。九宫/全拼使用四位前缀,14键使用三位前缀;短编码单独索引。解码缓存限制为三项且 JSON 文本合计不超过 48,000 字符,大分片不缓存。仅当前页最多16个候选进入展开网格,所有候选对象不放进响应式状态。转换器分别回读两种索引,验证与原始索引完全相同;不改写原始字典。
初期两位编码分片曾触发原生模拟器内存回收,已改为四位编码分片。验收保持既有 `--memory-profile 8192`,没有通过增大模拟器内存规避问题。模拟器耗时不能替代真机帧率、延迟或功耗结论。
@@ -0,0 +1,23 @@
# RTOS 1.2.5 内存、查词及等大26键验收
2026-11-14,版本1.2.5/10205,8192 KiB原生模拟器配置。
运行索引从91,357 B审计meta.json改为2,158 B的存在位图runtime.json;完整meta、原始词库和第三方声明保留。缓存限制3个分片及48,000个JSON字符,真正LRU,大分片不缓存,页面隐藏释放;清理期间的旧异步读取不能重新填满缓存。宿主同步工具同时复制运行索引。
移除全词库entries/frequencies复制,26键直接定位输入前缀,九宫/14键保留相关分片消歧。补全只保留12个最优且未被完整匹配覆盖的候选,保留零词频补全。过期查询在读文件后结束。全部匹配结果仍可分页浏览;只有当前最多3项首页候选进入响应式数据,展开时建立整数页起点,翻页只创建当前最多16个单元;反应式字段40降至29。
26字母维持10/9/7三排,三排使用一致的宽高。方屏利用全宽;圆屏调整上下留白、候选栏与模式栏,扩大字母触控区且保留完整底部操作。独立1px光标和大写/小写行为保持。
## 算法与构建
59项引擎、49项UX、组件同步测试及普通/QuickApp四配置生产构建通过。词库保留104,837组索引、163,238候选,两种索引完整回读一致。24种编码的完整匹配及补全与1.2.4逐项一致;12,000个混合候选恢复为原顺序,808页且当前页最多15项;7,291个运行索引存在位与审计清单完全一致。另验证完整匹配/补全去重、零词频补全、旧清单兼容与清理隔离。
同一词库、24种全拼/九宫/14键编码,5轮共120次查询:总耗时 227.12 → 194.87 ms,P95 6.70 → 5.01 ms,中位数 1.01 → 1.07 ms;GC后保留堆 1,149,800 → 805,848 B。总耗时减少14.2%,P95减少25.2%,保留堆减少29.9%。中位数略增,不能称所有输入都提速。
这是Windows Node/V8的对比,数值随主机负载变化;不是Glyphix MCU/真实手表的内存、帧率或触控延迟。原始报告及执行脚本随源码/验收ZIP提供。
## 原生组件与视觉
生产CrabInput组件逐字节复制到独立自动测试入口工程,报告保留组件和路由摘要。圆/方普通和QuickApp四配置共96状态/96张原生截图通过。检查8种文本/光标/私密状态、两主题与全拼/英文26键;26键宽高取整误差最多1px。模式切换与空格/删除/完成按钮同时检查矩形及圆屏四角安全边界;人工核对圆屏底部控件没有被父容器裁切。每配置实际点击字母和大小写按钮提交qQq。未使用或接管Inspector。
这些是原生测试工程的组件回归,不是RTOS真实手表或最终发布包全套端到端验收。原84项整包前后台交互未重跑;RTOS真机、长期性能/功耗和商店审核未验收。
@@ -48,6 +48,19 @@ def from94(value):
number = number * 94 + ord(ch) - 33
return number
def presence(names,alphabet,lengths,prefix):
masks=[]
for length in lengths:
mask=bytearray((len(alphabet)**length+7)//8)
for name in names:
code=name[len(prefix):] if name.startswith(prefix) else ''
if len(code)!=length or any(ch not in alphabet for ch in code):continue
index=0
for ch in code:index=index*len(alphabet)+alphabet.index(ch)
mask[index//8]|=1<<(index%8)
masks.append(mask.hex())
return masks
def convert(source, target):
target.parent.mkdir(parents=True, exist_ok=True)
assert target.resolve().is_relative_to((APP/'src/assets').resolve())
@@ -124,6 +137,15 @@ def _convert(source, target):
predictive_largest_shard_bytes=max((target/(name+'.json')).stat().st_size for name in [*association_shards,*completion_shards]),
source_files={name:hashlib.sha256(data).hexdigest() for name,data in sorted(files.items())})
if frequency_path.exists():report['t9_frequency_source']={'project':'jieba 0.42.1','license':'MIT','sha256':hashlib.sha256(frequency_path.read_bytes()).hexdigest()}
# Runtime needs only presence bits, not thousands of filenames and audit data.
runtime={'version':1,'short':presence(shards,'23456789',range(1,4),'s'),
'long':presence([name for name in shards if re.fullmatch('[2-9]{4}',name)],'23456789',[4],''),
'dualShort':presence(shards,'0123456789abcd',range(1,3),'ds'),
'dualLong':presence([name for name in shards if re.fullmatch('d[0-9a-d]{3}',name)],'0123456789abcd',[3],'d'),
'associations':presence(association_shards,'0123456789abcdef',[2],'a'),
'completions':presence(completion_shards,'0123456789abcdef',[2],'c')}
output=target/'runtime.json';data=(json.dumps(runtime,separators=(',',':'))+'\n').encode('utf-8')
if not output.exists() or output.read_bytes()!=data:output.write_bytes(data)
output=target/'meta.json';data=(json.dumps(report, ensure_ascii=False, indent=2)+'\n').encode('utf-8')
if not output.exists() or output.read_bytes()!=data:output.write_bytes(data)
print(json.dumps({**{k:v for k,v in report.items() if k not in ['source_files','shards']},'shards':len(shards)}, ensure_ascii=False))
@@ -14,7 +14,7 @@ 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
if(!fs.existsSync(metaFile)||!fs.existsSync(path.join(source,'src/assets/ime-dict/runtime.json')))return false
const meta=readJson(metaFile)
const seeds=path.join(source,'src/common/association-seeds.json')
if(fs.existsSync(seeds)&&meta.association_seed_sha256!==digest(fs.readFileSync(seeds)))return false
@@ -54,6 +54,8 @@ export function snapshot(source=APP,includeRoute=true){
for(const file of fs.readdirSync(path.join(source,'src/common')).filter(name=>/^ime-[a-z0-9-]+\.js$/.test(name)))add('src/common/'+file,path.join(source,'src/common',file))
const meta=readJson(path.join(source,'src/assets/ime-dict/meta.json'))
if(!Array.isArray(meta.shards)||!meta.shards.length||new Set(meta.shards).size!==meta.shards.length)throw Error('Dictionary is not generated or has invalid metadata')
const runtime=path.join(source,'src/assets/ime-dict/runtime.json')
if(fs.existsSync(runtime))add('src/assets/ime-dict/runtime.json',runtime)
for(const name of ['meta',...meta.shards,...(meta.association_shards||[]),...(meta.completion_shards||[])]){
if(name!=='meta'&&!/^(short|s?[0-9]+|ds?[0-9a-d]+|[ac][0-9a-f]{2})$/.test(name))throw Error('Invalid generated shard name')
add('src/assets/ime-dict/'+name+'.json',path.join(source,'src/assets/ime-dict',name+'.json'))
@@ -45,6 +45,23 @@ export function candidatePages(candidates) {
}
flushPage();return pages
}
// A page plan retains integer offsets; only the visible page gets cell objects.
export function candidatePageStarts(candidates){
const starts=[];let used=0,slots=0,rowUnits=0
for(let i=0;i<candidates.length;i++){
const length=chars(candidates[i].word).length,span=length===1?1:length<=4?2:4,units=span===4?Math.max(1,Math.ceil(length/12)):1
if(slots&&(slots+span>4||rowUnits!==units)){used+=rowUnits;slots=0}
if(!starts.length||used+units>4){starts.push(i);used=0;slots=0}
if(!slots)rowUnits=units
slots+=span
if(slots===4){used+=rowUnits;slots=0}
}
return starts
}
export function candidatePageRows(candidates,starts,page){
if(!starts.length)return []
return candidatePages(candidates.slice(starts[page],page+1<starts.length?starts[page+1]:candidates.length))[0]||[]
}
export function edit(text,cursor,insertion,maxLength) {
const value=chars(text), at=Math.max(0,Math.min(value.length,cursor))
const room=Math.max(0,maxLength-value.length), added=chars(insertion).slice(0,room)
@@ -90,81 +107,113 @@ export function learn(preferences,input,candidate) {
}
export function createDictionary(read) {
// Small shards and a bounded LRU avoid parsing the whole lexicon on an MCU.
const cache=[],pending={};let manifest=null
async function load(name) {
const existing=cache.find(x=>x.name===name)
if(existing)return existing.data
if(pending[name])return pending[name]
pending[name]=(async()=>{
const text=await read('/assets/ime-dict/'+name+'.json')
const data=typeof text==='string'?JSON.parse(text):text
const size=typeof text==='string'?text.length:0
cache.push({name,data,size});while(cache.length>4||cache.reduce((sum,x)=>sum+x.size,0)>90000)cache.shift()
return data
})()
try{return await pending[name]}finally{delete pending[name]}
const cache=[];let pending={},manifest=null,metadataTask=null,epoch=0,cacheChars=0,reads=0,readChars=0,hits=0
function hasBits(hex,code,alphabet){let index=0;for(const ch of code){const digit=alphabet.indexOf(ch);if(digit<0)return false;index=index*alphabet.length+digit}const byte=parseInt(hex.slice((index>>3)*2,(index>>3)*2+2),16)||0;return !!(byte&(1<<(index&7)))}
function exists(name){
if(manifest.version!==1)return ((name[0]==='a'?manifest.association_shards:name[0]==='c'?manifest.completion_shards:manifest.shards)||[]).indexOf(name)>=0
if(name[0]==='a'||name[0]==='c')return hasBits(manifest[name[0]==='a'?'associations':'completions'][0],name.slice(1),'0123456789abcdef')
const dual=name[0]==='d',short=name[dual?1:0]==='s',code=name.slice(dual?(short?2:1):(short?1:0))
const masks=manifest[dual?(short?'dualShort':'dualLong'):(short?'short':'long')]
const mask=masks&&masks[short?code.length-1:0]
return !!mask&&hasBits(mask,code,dual?'0123456789abcd':'23456789')
}
async function metadata(){if(!manifest){const meta=await read('/assets/ime-dict/meta.json');manifest=typeof meta==='string'?JSON.parse(meta):meta}return manifest}
async function predict(context){
async function readJson(name){const text=await read('/assets/ime-dict/'+name+'.json');reads++;if(typeof text==='string')readChars+=text.length;return {data:typeof text==='string'?JSON.parse(text):text,size:typeof text==='string'?text.length:0}}
async function load(name) {
const index=cache.findIndex(x=>x.name===name)
if(index>=0){const existing=cache.splice(index,1)[0];cache.push(existing);hits++;return existing.data}
if(pending[name])return pending[name]
const owner=epoch,task=(async()=>{
const entry=await readJson(name)
if(owner===epoch&&entry.size<=48000){cache.push({name,data:entry.data,size:entry.size});cacheChars+=entry.size;while(cache.length>3||cacheChars>48000)cacheChars-=cache.shift().size}
return entry.data
})()
pending[name]=task
try{return await task}finally{if(pending[name]===task)delete pending[name]}
}
async function metadata(){
if(manifest)return manifest
if(!metadataTask){const owner=epoch;metadataTask=(async()=>{let entry;try{entry=await readJson('runtime')}catch(error){entry=await readJson('meta')}if(owner===epoch)manifest=entry.data;return entry.data})()}
return metadataTask
}
async function predict(context,current){
const owner=epoch,previous=current;current=()=>owner===epoch&&(!previous||previous())
const tail=contextTail(context);if(!tail)return []
const meta=await metadata(),result=[],seen={}
if(current&&!current())return []
for(let length=tail.length;length>0&&result.length<8;length--){
const prefix=tail.slice(-length),name='a'+predictiveBucket(prefix)
if(!meta.association_shards||meta.association_shards.indexOf(name)<0)continue
if(!exists(name))continue
const rows=(await load(name))[prefix]||[]
if(current&&!current())return []
for(const row of rows)if(!seen[row[0]]){seen[row[0]]=true;result.push({word:row[0],pinyin:'',consume:0,frequency:row[1]});if(result.length===8)break}
}
return result
}
return {
predict,
async lookup(raw,t9,preferences,context) {
async lookup(raw,t9,preferences,context,current) {
const owner=epoch,previous=current;current=()=>owner===epoch&&(!previous||previous())
const input=String(raw||'').toLowerCase()
const dual=t9==='14',ambiguous=!!t9
if(!(dual?/^[0-9a-d]{1,32}$/:t9?/^[2-9]{1,32}$/:/^[a-z]{1,32}$/).test(input))return []
const digits=ambiguous?input:digitsOf(input)
const merged={},frequencies={}
function merge(data){Object.assign(merged,data.entries);Object.assign(frequencies,data.frequencies)}
await metadata()
if(current&&!current())return []
const names=[]
const prefixLength=dual?3:4
for(let length=1;length<=Math.min(prefixLength-1,digits.length);length++)names.push((dual?'ds':'s')+digits.slice(0,length))
if(digits.length>=prefixLength)names.push((dual?'d':'')+digits.slice(0,prefixLength))
for(const name of names)if(manifest.shards.indexOf(name)>=0)try{merge(await load(name))}catch(error){throw new Error('词库读取失败')}
const matching=Object.keys(merged).filter(key=>input.indexOf(dual?code14Of(key):t9?digitsOf(key):key)===0)
matching.sort((a,b)=>b.length-a.length||(a<b?-1:a>b?1:0))
const result=[],seen={}
for(const key of matching)for(const word of merged[key]){
const identity=word+'@'+key.length
if(!seen[identity]){seen[identity]=true;result.push({word,pinyin:key,consume:key.length,frequency:frequencies[word]||0})}
const matching=[],extras=[],result=[],seen={};let completionData=null
function offer(word,key,frequency){
if(seen[word+'@'+input.length])return
const missing=key.length-input.length,old=extras.findIndex(x=>x.word===word)
if(old>=0){if(extras[old].frequency>frequency||extras[old].frequency===frequency&&extras[old].missing<=missing)return;extras.splice(old,1)}
let at=0;while(at<extras.length&&(extras[at].frequency>frequency||extras[at].frequency===frequency&&extras[at].missing<=missing))at++
if(at<12){extras.splice(at,0,{word,pinyin:key,frequency,missing,consume:input.length,completion:true});if(extras.length>12)extras.pop()}
}
for(const name of names)if(exists(name)){
const data=await load(name)
if(current&&!current())return []
if(!ambiguous){
for(let length=1;length<=input.length;length++){const key=input.slice(0,length);if(data.entries[key])matching.push({key,words:data.entries[key],weights:data.frequencies})}
}
if(input.length>=prefixLength)completionData=data
if(ambiguous)for(const key in data.entries){
const code=dual?code14Of(key):t9?digitsOf(key):key
if(input.indexOf(code)===0)matching.push({key,words:data.entries[key],weights:data.frequencies})
}
}
matching.sort((a,b)=>b.key.length-a.key.length||(a.key<b.key?-1:a.key>b.key?1:0))
for(const entry of matching)for(const word of entry.words){const identity=word+'@'+entry.key.length;if(!seen[identity]){seen[identity]=true;result.push({word,pinyin:entry.key,consume:entry.key.length,frequency:entry.weights[word]||0})}}
if(input.length>=2){
const mode=dual?'14':t9?'t9':'py',prefix=mode+':'+input,extras=[]
const mode=dual?'14':t9?'t9':'py',prefix=mode+':'+input
if(input.length<prefixLength){
const name='c'+predictiveBucket(prefix)
if(manifest.completion_shards&&manifest.completion_shards.indexOf(name)>=0){
for(const row of (await load(name))[prefix]||[])extras.push({word:row[0],pinyin:row[1],frequency:row[2],missing:row[1].length-input.length,consume:input.length,completion:true})
if(exists(name)){
const rows=(await load(name))[prefix]||[]
if(!current())return []
for(const row of rows)offer(row[0],row[1],row[2])
}
}else for(const key of Object.keys(merged)){
}else if(completionData)for(const key in completionData.entries){
const code=dual?code14Of(key):t9?digitsOf(key):key
if(code.length<=input.length||code.length>input.length+4||code.indexOf(input)!==0)continue
for(const word of merged[key])extras.push({word,pinyin:key,frequency:frequencies[word]||0,missing:key.length-input.length,consume:input.length,completion:true})
if(code.length>input.length&&code.length<=input.length+4&&code.indexOf(input)===0)for(const word of completionData.entries[key])offer(word,key,completionData.frequencies[word]||0)
}
extras.sort((a,b)=>b.frequency-a.frequency||a.missing-b.missing)
let added=0
for(const item of extras){const identity=item.word+'@'+item.consume;if(!seen[identity]){seen[identity]=true;result.push(item);if(++added===12)break}}
for(const item of extras){const identity=item.word+'@'+item.consume;if(!seen[identity]){seen[identity]=true;result.push(item)}}
}
const preferenceKey=dual?'k14:'+input:input
const prefs=preferences&&Array.isArray(preferences[preferenceKey])?preferences[preferenceKey]:[]
const rank=item=>{const i=prefs.findIndex(x=>x.word===item.word&&x.consume===item.consume);return i<0?100:i}
const predictions=await predict(context),contextRank=item=>{const i=predictions.findIndex(x=>x.word===item.word);return i<0?0:8-i}
const ranks={},contexts={};for(let i=0;i<prefs.length;i++)ranks[prefs[i].word+'@'+prefs[i].consume]=i
const predictions=await predict(context,current);if(current&&!current())return []
for(let i=0;i<predictions.length;i++)contexts[predictions[i].word]=8-i
const rank=item=>{const value=ranks[item.word+'@'+item.consume];return value===undefined?100:value},contextRank=item=>contexts[item.word]||0
const category=item=>item.completion?1:item.consume===input.length?0:2
// Preserve every exact candidate; complete unfinished syllables before
// shorter fallbacks, and use word frequency plus the editor's local context.
return result.map((item,index)=>({item,index})).sort((a,b)=>rank(a.item)-rank(b.item)||category(a.item)-category(b.item)||b.item.consume-a.item.consume||contextRank(b.item)-contextRank(a.item)||(b.item.frequency||0)-(a.item.frequency||0)||a.index-b.index).map(x=>x.item)
for(let i=0;i<result.length;i++)result[i].order=i
return result.sort((a,b)=>rank(a)-rank(b)||category(a)-category(b)||b.consume-a.consume||contextRank(b)-contextRank(a)||(b.frequency||0)-(a.frequency||0)||a.order-b.order)
},
clear(){cache.length=0;manifest=null},
get cacheSize(){return cache.length}
clear(){epoch++;cache.length=0;cacheChars=0;pending={};manifest=null;metadataTask=null},
get cacheSize(){return cache.length},
get stats(){return {reads,readChars,hits,cacheChars,cacheEntries:cache.length}}
}
}
@@ -7,10 +7,15 @@
<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 if="mode === 'py'" id="ime-keypad" class="keypad qwerty-pad pinyin-pad">
<div class="keyrow qwerty-first"><button for="key in keyRows[0]" :id="'ime-key-' + key.value.toLowerCase()" class="key qwerty-key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
<div class="keyrow qwerty-middle"><button for="key in keyRows[1]" :id="'ime-key-' + key.value.toLowerCase()" class="key qwerty-key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
<div class="keyrow qwerty-last"><button for="key in keyRows[2]" :id="'ime-key-' + key.value.toLowerCase()" class="key qwerty-key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
</div>
<div if="mode === 'en'" id="ime-keypad" class="keypad qwerty-pad english-pad">
<div class="keyrow qwerty-first"><button for="key in keyRows[0]" :id="'ime-key-' + key.value.toLowerCase()" class="key qwerty-key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
<div class="keyrow qwerty-middle"><button for="key in keyRows[1]" :id="'ime-key-' + key.value.toLowerCase()" class="key qwerty-key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
<div class="keyrow qwerty-last"><button for="key in keyRows[2]" :id="'ime-key-' + key.value.toLowerCase()" class="key qwerty-key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p></button></div>
</div>
<div if="mode !== 'py' &amp;&amp; mode !== 'en'" id="ime-keypad" class="keypad">
<div for="row in layoutRows" class="keyrow" :style="row.style"><button for="key in row.keys" class="key" :style="keyStyle" on:click="press(key.value)"><p class="key-label" :style="labelStyle">{{key.label}}</p><p if="key.sub" class="key-sub" :style="themeStyles.dim">{{key.sub}}</p></button></div>
@@ -50,30 +55,31 @@
.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%}
.qwerty-pad{width:100%}.qwerty-first{width:100%}.qwerty-middle{width:90%}.qwerty-last{width:70%}.qwerty-key{margin:2px;height:58px}.english-pad .qwerty-key{height:76px}
.modes{height:36px;margin-top:3px}.modes button{flex:1;height:31px;background-color:transparent;font-size:.53rem;color:#636A77}
.bottom{height:41px;margin-top:2px}.bottom button{flex:1;height:37px;font-size:.6rem;background-color:#FFFFFF}.bottom .done{background-color:#2867D9;color:#FFFFFF}.bottom .delete{background-color:#D9DDE4;color:#515866}
.page-info{text-align:center;color:#8B919D;font-size:.45rem;margin:5px 0;flex-shrink:0}.full-list{flex:1;justify-content:flex-start}.candidate-grid-row{display:flex;flex-direction:row;align-self:flex-start;align-items:stretch;flex-shrink:0}.full-candidate{display:flex;align-items:center;justify-content:center;flex:1;min-width:0;margin:2px;padding:2px 4px;background-color:#FFFFFF;border-radius:10px}.word{width:100%;text-align:center;font-size:25px;color:#22252E}.empty{margin:15px 8px;color:#9299A5;text-align:center}
.pager{height:38px}.pager button{flex:1;height:34px;background-color:transparent;color:#2867D9;font-size:.55rem}.toolrow button{flex:1;height:40px;font-size:.57rem}.tools{justify-content:center}.tools .danger{color:#C85656}.tools .bottom{margin-top:8px}
@media (shape:circle){.ime{padding:30px 8px}.top{align-self:center;width:186px;height:24px}.cancel{height:24px}.preview{align-self:center;width:280px;height:32px;margin-top:5px}.composition{align-self:center;width:300px;height:23px}.candidates{align-self:center;width:352px;height:38px}.candidate{height:34px}.more{height:34px}.keypad{align-self:center;width:368px}.key{height:49px}.modes{align-self:center;width:286px;height:33px}.modes button{height:29px}.bottom{align-self:center;width:200px;height:38px}.bottom button{height:34px}.full-list{align-self:center;width:340px}.pager{align-self:center;width:276px}.toolrow{align-self:center;width:300px}.toolrow button{height:34px}.tools .page-info{align-self:center;width:280px}.tools .bottom{margin-top:4px}}
@media (shape:circle){.qwerty-first{width:92%}.qwerty-first-py{width:96%}.qwerty-middle{width:100%}.qwerty-last{width:90%}.qwerty-last-py{width:80%}}
@media (shape:circle){.ime{padding:22px 8px}.candidates{height:32px}.candidate,.more{height:26px}.modes{margin-top:1px}.qwerty-pad{width:98%}.pinyin-pad .qwerty-key{height:54px}.english-pad{width:92%}.english-pad .qwerty-key{height:65px}.bottom{width:176px}}
</style>
<script>
import file from '@system.file'
import storage from '@system.storage'
import {registerInput,unregisterInput} from '../../common/ime-lifecycle.js'
import {normalizeTheme, themeStyles as stylesForTheme} from '../../common/ime-theme.js'
import {createDictionary,chars,contextTail,candidatePages,edit,erase,previewParts,learn,sanitizePreferences,KEY14_GROUPS,display14} from '../../common/ime-engine.js'
import {createDictionary,chars,contextTail,candidatePageStarts,candidatePageRows,edit,erase,previewParts,learn,sanitizePreferences,KEY14_GROUPS,display14} from '../../common/ime-engine.js'
const LETTERS=['qwertyuiop','asdfghjkl','zxcvbnm']
const SYMBOLS=['!"#$%&',"'()*+,",'-./:;<','=>?@[\\',']^_`{|','}~,。?!']
function keys(rows){return rows.map(row=>Array.from(row).map(value=>({label:value,value:value,sub:''})))}
export default {
data:{config:{},titleLabel:'蟹蟹输入',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},
data:{config:{},titleLabel:'蟹蟹输入',themeStyles:stylesForTheme('bluewhite'),inputType:'text',allowNewline:true,secret:false,composition:'',compositionDisplay:'',previewBefore:'',previewAfter:'',count:0,mode:'t9',modeLabel:'九宫拼音',upper:false,symbolPage:0,panel:'keys',keyRows:[],layoutRows:[],keyStyle:'',labelStyle:'',bar:[],candidateRows:[],total:0,candidatePage:0,pageCount:1,busy:false,hint:'离线全量词库',learning:false,clearArmed:false},
title:'蟹蟹输入',inputTheme:'bluewhite',initial:'',maxLength:2048,revealSecret:false,text:'',cursor:0,display:'',limit:2048,visibleCandidates:[],
dictionary:null,preferences:{},allCandidates:[],pages:[],prediction:false,active:true,life:null,requestId:0,lookupTimer:null,repeatTimer:null,repeatDelay:null,ended:false,original:'',
onInit(){
for(const key of ['initial','title','inputType','maxLength','allowNewline','inputTheme','secret','revealSecret'])if(this.config[key]!==undefined)this[key]=this.config[key]
registerInput(this)
this.allCandidates=[];this.pages=[];this.visibleCandidates=[];registerInput(this)
this.titleLabel=chars(this.title).slice(0,4).join('')+(chars(this.title).length>4?'…':'');this.inputTheme=normalizeTheme(this.inputTheme);this.themeStyles=stylesForTheme(this.inputTheme)
this.life={active:true,id:0};this.secret=!!this.secret||this.inputType==='password'
this.limit=Math.floor(Math.max(1,Math.min(4096,Number(this.maxLength)||2048)))
@@ -84,7 +90,7 @@
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()},
onHide(){this.active=false;this.life.active=false;this.invalidate();this.stopRepeat();if(this.dictionary)this.dictionary.clear();if(this.secret)this.clearPrivate()},
clearPrivate(){this.active=false;this.life.active=false;this.invalidate();this.stopRepeat();this.text='';this.initial='';this.original='';this.composition='';this.allCandidates=[];this.bar=[];this.visibleCandidates=[];this.candidateRows=[];this.pages=[];this.total=0;this.config={};this.updateDisplay()},
onDestroy(){unregisterInput(this);this.active=false;if(this.life)this.life.active=false;this.invalidate();this.stopRepeat();if(this.dictionary)this.dictionary.clear();this.allCandidates=[];this.preferences={};this.text='';this.initial='';this.original='';this.config={}},
invalidate(){this.requestId++;this.life.id=this.requestId;if(this.lookupTimer!==null){clearTimeout(this.lookupTimer);this.lookupTimer=null}this.busy=false},
@@ -98,8 +104,7 @@
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.keyStyle=(qwerty?'':'height:'+(this.keyRows.length<=3?'49':this.keyRows.length===4?'38':this.keyRows.length===5?'28':'22')+'px;')+this.themeStyles.card
this.labelStyle=(qwerty?'font-size:22px;':this.mode==='14'?'font-size:24px;':'')+this.themeStyles.ink
this.layoutRows=this.keyRows.map((row,index)=>({keys:row,style:qwerty?(this.mode==='py'&&index!==1?'width:'+(index===0?'96':'80')+'%;':''):'width:'+(index===this.keyRows.length-1?'91':'100')+'%'}))
this.modeLabel={t9:'九宫拼音',py:'26键全拼 · v 代表 ü','14':'14键拼音',en:this.upper?'英文大写':'英文小写',num:'数字',sym:'符号 · '+(this.symbolPage+1)+'/2'}[this.mode]
@@ -129,13 +134,13 @@
},
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)
try{const current=()=>life.active&&id===life.id;const result=raw?await this.dictionary.lookup(raw,this.mode==='14'?'14':this.mode==='t9',this.learning?this.preferences:{},context,current):await this.dictionary.predict(context,current)
if(!life.active||id!==life.id)return
this.prediction=!raw;this.allCandidates=result;this.total=result.length;this.bar=result.slice(0,3);this.pages=candidatePages(result);this.pageCount=Math.max(1,this.pages.length);this.candidatePage=0;this.pageCandidates();this.hint=result.length?(!raw?'联想 · ':'')+result.length+' 个候选':raw?'暂无匹配 · 可退格修改':'离线输入'
this.prediction=!raw;this.allCandidates=result;this.total=result.length;this.bar=result.slice(0,3);this.pages=[];this.pageCount=1;this.candidatePage=0;if(this.panel==='candidates')this.pageCandidates();else{this.visibleCandidates=[];this.candidateRows=[]}this.hint=result.length?(!raw?'联想 · ':'')+result.length+' 个候选':raw?'暂无匹配 · 可退格修改':'离线输入'
}catch(error){if(life.active&&id===life.id){this.clearCandidates();this.hint='词库暂不可用,可切换英文输入'}}
finally{if(life.active&&id===life.id)this.busy=false}
},
pageCandidates(){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}})}))},
pageCandidates(){if(!this.pages.length)this.pages=candidatePageStarts(this.allCandidates);this.pageCount=Math.max(1,this.pages.length);const rows=candidatePageRows(this.allCandidates,this.pages,this.candidatePage);this.visibleCandidates=[];this.candidateRows=rows.map(row=>({style:'height:'+(row.units*43)+'px;width:'+(row.slots*25)+'%',items:row.items.map(entry=>{this.visibleCandidates.push(entry.item);return {item:entry.item,style:'flex:'+entry.span+';height:'+(row.units*43-4)+'px;'+this.themeStyles.card,labelStyle:'font-size:'+(entry.span===1?25:entry.span===2?23:20)+'px;'+this.themeStyles.ink}})}))},
choose(item){
if(this.busy||(!this.composition&&(!this.prediction||item.consume!==0))||!this.allCandidates.some(x=>x.word===item.word&&x.pinyin===item.pinyin&&x.consume===item.consume))return
if(chars(this.text).length+chars(item.word).length>this.limit){this.hint='剩余空间不足,未截断候选词';return}
@@ -2,8 +2,8 @@
"package": "com.sydxky.rtos.inputmethod",
"name": "蟹蟹输入",
"icon": "/assets/icon.png",
"versionName": "1.2.4",
"versionCode": 10204,
"versionName": "1.2.5",
"versionCode": 10205,
"features": [],
"router": {
"entry": "main",
@@ -33,7 +33,7 @@ p=page({inputType:'password',initial:'😀secret'});p.toggleLearning();check(!p.
p=page({inputType:'password',revealSecret:true,initial:'Visible123!'});check(p.display.includes('Visible123!')&&p.secret&&!p.learning,'explicit password reveal preserves private input');p.toggleLearning();check(!p.learning,'revealed password cannot enable learning');p.onHide();check(!p.text&&!p.initial&&!p.original,'revealed password clears on hide')
p=page({initial:'你😀好'});p.cursor=2;p.backspace();check(p.text==='你好','UI Unicode deletion')
p.startDelete();check(p.repeatDelay!==null,'delete hold delay starts');p.onHide();check(p.repeatDelay===null&&p.repeatTimer===null,'hide stops repeat timers')
p=page();let resolve;p.dictionary={lookup:()=>new Promise(yes=>resolve=yes)};p.composition='64';const pending=p.query(p.requestId);p.onHide();resolve([{word:'你',consume:2,pinyin:'ni'}]);await pending;check(p.total===0,'late dictionary response cannot update hidden page')
p=page();let resolve;p.dictionary={lookup:()=>new Promise(yes=>resolve=yes),clear(){}};p.composition='64';const pending=p.query(p.requestId);p.onHide();resolve([{word:'你',consume:2,pinyin:'ni'}]);await pending;check(p.total===0,'late dictionary response cannot update hidden page')
p=page();p.mode='py';p.composition='shi';await p.query(p.requestId);p.expand();check(p.visibleCandidates.length===16&&p.candidateRows.length===4,'expanded single characters use 16 real candidate cells');p.nextPage();check(p.candidatePage===1&&p.visibleCandidates[0]===p.allCandidates[16],'next grid page continues without omissions');check(!ux.includes('item.pinyin}}')&&!ux.includes('class="reading"'),'expanded cells do not display pronunciation');
p=page({maxLength:2,initial:'你'});p.composition='nihao';p.allCandidates=[{word:'你好',consume:5,pinyin:'nihao'}];p.choose(p.allCandidates[0]);check(p.text==='你'&&p.composition==='nihao','candidate is not silently truncated')
p.finish();check(p.composition==='nihao','finish does not silently drop uncommitted composition')
@@ -26,7 +26,8 @@ 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);}
var controls=[];for(var id of ['ime-chinese','ime-14','ime-english','ime-tools','ime-space','ime-delete','ime-finish'])controls.push({key:id,rect:await rect(id)});
console.log('PROBE_CAPTURE '+JSON.stringify({name:theme+'-'+mode+'-'+(upper?'upper':'lower'),theme:theme,kind:'qwerty',keys:keys,controls:controls}));await tc.wait(1.1);}
}
p.invalidate();p.secret=false;p.text='';p.cursor=0;p.composition='';p.mode='en';p.upper=false;p.renderKeys();p.updateDisplay();await tc.wait(.2);
await tc.getById('ime-key-q').click();await tc.getById('ime-english').click();await tc.wait(.15);check(p.upper,'uppercase enabled');await tc.getById('ime-key-q').click();await tc.getById('ime-english').click();await tc.wait(.15);check(!p.upper,'lowercase restored');await tc.getById('ime-key-q').click();check(p.text==='qQq','actual native keys commit qQq');
@@ -81,11 +82,18 @@ def main():
state['passed']=abs(state['left_gap_px'])<=1 and abs(state['right_gap_px'])<=1 and 0<caret[2]<=2 and preview[0]<=caret[0]and caret[0]+caret[2]<=preview[0]+preview[2]
else:
width=466 if profile.startswith('round')else 390;height=466 if profile.startswith('round')else 450;failures=[]
widths=[];heights=[]
for key in state['keys']:
x,y,w,h=rectangle(key['rect'])
widths.append(w);heights.append(h)
if w<=0 or h<=0 or x<0 or y<0 or x+w>width or y+h>height:failures.append(key['key'])
if profile.startswith('round')and max(math.hypot(tx-233,ty-233)for tx in [x,x+w]for ty in [y,y+h])>231:failures.append(key['key'])
state.update(failures=failures,passed=len(state['keys'])==26 and not failures)
equal=max(widths)-min(widths)<=1 and max(heights)-min(heights)<=1
for control in state['controls']:
x,y,w,h=rectangle(control['rect'])
if w<=0 or h<=0 or x<0 or y<0 or x+w>width or y+h>height:failures.append(control['key'])
if profile.startswith('round')and max(math.hypot(tx-233,ty-233)for tx in [x,x+w]for ty in [y,y+h])>231:failures.append(control['key'])
state.update(failures=failures,key_width_px=[min(widths),max(widths)],key_height_px=[min(heights),max(heights)],equal_size=equal,passed=len(state['keys'])==26 and not failures and equal)
captures+=1;report['cases'].append(state);native.save(args.output/'caret-results.json',report);print(json.dumps(state,ensure_ascii=False),flush=True)
if 'PROBE_DONE'in line:finished=True
if finished:break
@@ -0,0 +1,65 @@
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import {pathToFileURL,fileURLToPath} from 'node:url'
import assert from 'node:assert/strict'
import * as current from '../src/common/ime-engine.js'
const app=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..')
const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1]
assert(args.includes('--baseline'),'Use --baseline <v1.2.4 ime-engine.js>')
const baseline=await import(pathToFileURL(path.resolve(option('--baseline'))))
const cases=[]
for(const text of ['shi','shij','shijie','niha','nihao','beij','beijing','zhongguo'])for(const mode of [false,true,'14'])cases.push([mode===false?text:mode==='14'?current.code14Of(text):current.digitsOf(text),mode])
const identity=rows=>rows.map(x=>x.word+'@'+x.pinyin+'@'+x.consume+'@'+!!x.completion).sort()
async function sample(module){
let reads=0,readChars=0,largestRead=0,resultCount=0
const dictionary=module.createDictionary(async uri=>{const text=fs.readFileSync(app+'/src'+uri,'utf8');reads++;readChars+=text.length;largestRead=Math.max(largestRead,text.length);return text})
if(global.gc)global.gc()
const before=process.memoryUsage().heapUsed,times=[],rows=[]
for(let round=0;round<5;round++)for(const [raw,mode]of cases){
const start=performance.now(),result=await dictionary.lookup(raw,mode,{},'你好');times.push(performance.now()-start)
if(!round)rows.push(identity(result));resultCount+=result.length
}
if(global.gc)global.gc()
const retainedHeapBytes=process.memoryUsage().heapUsed-before
const sorted=times.slice().sort((a,b)=>a-b)
const metrics={queries:times.length,resultCount,reads,readChars,largestRead,retainedHeapBytes,medianMs:sorted[Math.floor(sorted.length/2)],p95Ms:sorted[Math.floor(sorted.length*.95)],totalMs:times.reduce((a,b)=>a+b,0),stats:dictionary.stats||null}
dictionary.clear();return {metrics,rows}
}
const old=await sample(baseline),now=await sample(current)
assert.deepEqual(now.rows,old.rows,'Exact candidates and completions survive optimization in all three layouts')
assert.equal(now.metrics.resultCount,old.metrics.resultCount)
const meta=JSON.parse(fs.readFileSync(app+'/src/assets/ime-dict/meta.json','utf8'))
const runtime=JSON.parse(fs.readFileSync(app+'/src/assets/ime-dict/runtime.json','utf8'))
const masks=[['short','s','23456789'],['long','','23456789'],['dualShort','ds','0123456789abcd'],['dualLong','d','0123456789abcd'],['associations','a','0123456789abcdef'],['completions','c','0123456789abcdef']]
const restoredNames=[]
for(const [field,prefix,alphabet]of masks)for(let mask=0;mask<runtime[field].length;mask++){
const length=field==='short'||field==='dualShort'?mask+1:field==='long'?4:field==='dualLong'?3:2,bits=runtime[field][mask]
for(let i=0;i<alphabet.length**length;i++)if(parseInt(bits.slice((i>>3)*2,(i>>3)*2+2),16)&1<<(i&7)){
let code='',value=i;for(let digit=0;digit<length;digit++){code=alphabet[value%alphabet.length]+code;value=Math.floor(value/alphabet.length)}restoredNames.push(prefix+code)
}
}
assert.deepEqual(restoredNames.sort(),[...meta.shards,...meta.association_shards,...meta.completion_shards].sort(),'Every runtime presence bit matches the audit manifest, including absent buckets')
// Duplicate exact words must not consume the bounded completion budget. Legacy
// manifests without predictive lists remain compatible; zero-frequency rows survive.
const source={meta:{shards:['2223']},'2223':{entries:{abcd:['同'],abcde:['同',...Array.from({length:15},(_,i)=>'补'+i)]},frequencies:{同:999}}}
const edge=current.createDictionary(async uri=>{const name=path.basename(uri,'.json');if(!(name in source))throw new Error('absent');return JSON.stringify(source[name])})
const edgeRows=await edge.lookup('abcd',false,{},'')
assert.equal(edgeRows.filter(x=>x.completion).length,12);assert.equal(edgeRows.filter(x=>x.word==='同').length,1)
assert((await edge.predict('你')).length===0);edge.clear()
const candidates=Array.from({length:12000},(_,i)=>({word:i%13===0?'完整词组':String.fromCodePoint(0x4e00+i%1200)}))
const plan=current.candidatePageStarts(candidates),restored=[]
let maxVisible=0
for(let page=0;page<plan.length;page++){
const rows=current.candidatePageRows(candidates,plan,page),visible=rows.flatMap(row=>row.items.map(cell=>cell.item));restored.push(...visible);maxVisible=Math.max(maxVisible,visible.length)
assert(rows.reduce((sum,row)=>sum+row.units,0)<=4)
}
assert.deepEqual(restored,candidates);assert(maxVisible<=16)
// A clear while file I/O is pending must not refill a closed session's cache.
let release;const paused=current.createDictionary(uri=>uri.endsWith('runtime.json')?new Promise(resolve=>release=resolve):fs.readFileSync(app+'/src'+uri,'utf8'))
const obsolete=paused.lookup('shi',false,{},'',()=>false);paused.clear();release(fs.readFileSync(app+'/src/assets/ime-dict/runtime.json','utf8'));assert.equal((await obsolete).length,0);assert.equal(paused.cacheSize,0)
const reactive=text=>{const source=text.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .+$/gm,'').replace('export default','module.exports=');const module={exports:{}};vm.runInNewContext(source,{module,stylesForTheme:()=>({})});return Object.keys(module.exports.data).length}
const report={method:'Node V8 comparison with identical dictionary; heap figures are host measurements, not MCU or watch RAM',exactCandidateCases:cases.length,completionCases:cases.length,runtimePresenceShards:restoredNames.length,baseline:old.metrics,optimized:now.metrics,pagePlan:{candidates:candidates.length,pages:plan.length,maxVisibleCells:maxVisible},runtimeIndexBytes:fs.statSync(app+'/src/assets/ime-dict/runtime.json').size,auditMetadataBytes:fs.statSync(app+'/src/assets/ime-dict/meta.json').size,reactiveProperties:{baseline:args.includes('--baseline-component')?reactive(fs.readFileSync(option('--baseline-component'),'utf8')):null,optimized:reactive(fs.readFileSync(app+'/src/components/CrabInput/index.ux','utf8'))},passed:true}
if(args.includes('--output'))fs.writeFileSync(option('--output'),JSON.stringify(report,null,2)+'\n')
console.log(JSON.stringify(report))
@@ -10,7 +10,7 @@ function write(root,name,body){const file=path.join(root,name);fs.mkdirSync(path
function fixture(root){
write(root,'src/manifest.json',JSON.stringify({versionName:'fixture',router:{pages:{}}}))
for(const name of ['keyboard/index.ux','components/CrabInput/index.ux','assets/ime-icons/icon.png',...['engine','theme','lifecycle'].map(n=>'common/ime-'+n+'.js')])write(root,'src/'+name,'first')
write(root,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['2222']}));write(root,'src/assets/ime-dict/2222.json','{}')
write(root,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['2222']}));write(root,'src/assets/ime-dict/2222.json','{}');write(root,'src/assets/ime-dict/runtime.json','{"version":1}')
for(const name of ['crabKeyboard','jieba','remix-icon'])write(root,'third_party/'+name+'/LICENSE','MIT')
write(root,'LICENSE','MIT');write(root,'NOTICE.md','third party notices')
}
@@ -18,7 +18,8 @@ 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 first=syncInput(host,source);assert.equal(first.copied,14);assert.equal(first.removed,1);checkInput(host,source)
assert.equal(fs.readFileSync(path.join(host,'src/assets/ime-dict/runtime.json'),'utf8'),'{"version":1}','Compact runtime index is included in host synchronization')
const timestamp=fs.statSync(path.join(host,'src/components/CrabInput/index.ux')).mtimeMs
assert.equal(syncInput(host,source).copied,0);assert.equal(fs.statSync(path.join(host,'src/components/CrabInput/index.ux')).mtimeMs,timestamp)
write(host,'src/common/ime-theme.js','stale');assert.throws(()=>checkInput(host,source),/differs/);assert.equal(syncInput(host,source).copied,1)