fix(输入法): 优化双端联想排序并消除文字刷新闪烁
这个提交包含在:
1 父节点
bce6f8083b
当前提交
b95e7caae7
31 个文件已更改
+504
-124
没有与搜索条件相匹配的文件
@@ -4,6 +4,8 @@
|
||||
|
||||
正在编辑的文本仅在本次页面的内存中。主动“保存草稿”后,普通文本保存于本应用本地存储,供下次启动恢复;可清空全文后重新保存以删除旧草稿,卸载应用也会清除该存储。私密输入不提供保存、不参与学习,切换其他应用或销毁页面时清空编辑状态。
|
||||
|
||||
离线上下文联想仅使用本次编辑光标前最多4个连续汉字,不跨标点或非中文字符,不保存联想上下文;密码/私密输入不参与。续写索引来自已附许可的现有词库与本项目自编的少量常用短语,不增加网络请求。
|
||||
|
||||
候选学习默认关闭。开启后仅在本应用保存最多 100 组编码、每组三个主动选择的真实候选;不会保存全文。在编辑页可关闭学习或“清除学习”。关闭学习不会自动删除旧记录,可通过清除按钮删除。
|
||||
|
||||
嵌入其他 Glyphix 应用后,键盘数据保存在该宿主自己的隔离空间。完成输入会将文本回填给宿主;宿主后续的网络行为适用其自身说明。腕上浏览的兼容模式会在明确告知后把网页输入交给服务器,详见其独立隐私政策。
|
||||
|
||||
@@ -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.2 / 10202`。基于 [CrabKeyboard](https://github.com/crabPangXie/crabKeyboard) 的 MIT 全量词库和拼音前缀消费方式,重写为 Glyphix `.ux` 页面;不依赖 HarmonyOS HML、Android 输入法服务、DOM 或网络。
|
||||
独立应用 `com.sydxky.rtos.inputmethod`,版本 `1.2.3 / 10203`。基于 [CrabKeyboard](https://github.com/crabPangXie/crabKeyboard) 的 MIT 全量词库和拼音前缀消费方式,重写为 Glyphix `.ux` 页面;不依赖 HarmonyOS HML、Android 输入法服务、DOM 或网络。
|
||||
|
||||
1.2.3 新增未完成拼音补全、离线上下文续写和词频排序;查询期间保留候选,避免先清空再重画。验证范围见[本版验收](./VALIDATION_1.2.3.md)。
|
||||
|
||||
1.2.2 修复文字与键盘之间的多余空隙,圆屏按键上移后同步检查安全边界;本版验证范围见[间距修复验收](./VALIDATION_1.2.2.md)。
|
||||
|
||||
@@ -18,7 +20,7 @@
|
||||
|
||||
- 点击首页文字区或“开始输入”即打开满屏键盘。完成回填;取消保留原文。草稿需主动保存,仅保存在本应用。
|
||||
- 默认九宫大按键。“26键”切到标准 QWERTY 全拼,`v` 表示 `ü`;“九宫”切回九宫。“14键”直接进入双字母模式。连续点击对应字母组,选择候选后只消费匹配部分,剩余编码继续查词。存在未选词的九宫或14键编码时禁止切换模式/完成,避免丢字或把编码当文字提交。
|
||||
- 第一行显示三个候选。点击右侧下箭头按每页四项查看全部匹配候选,左右箭头翻页,不使用原项目扩展列表的 100 项截断。拼音候选保留每组原始顺序;九宫和14键歧义通过本地 jieba 0.42.1 词频排序,学习偏好可优先排序。
|
||||
- 第一行显示三个候选。点击右侧下箭头按4×4自适应分页查看全部匹配候选,左右箭头翻页,不使用原项目扩展列表的 100 项截断。完整拼音、九宫和14键先匹配完整编码,再提供未完成拼音补全和较短前缀;通过本地 jieba 0.42.1 词频及当前光标前的中文上下文排序,学习偏好可优先排序。
|
||||
- 支持英文大小写、数字、完整可打印 ASCII 标点与常用中文标点。符号分两组大按键,通过“下一组符号”切换。网址额外提供 `https://`、`.com`。
|
||||
- 点击文本预览或铅笔图标移动光标、换行、上屏全拼、清空全文。Unicode 字符按码点计数和删除,避免拆坏 emoji。清空全文需第二次点击确认;长按退格图标连续删除,松开/隐藏/销毁停止。勾号完成,叉号取消,键盘图标返回输入。
|
||||
- 学习默认关闭,可开启或清除,仅保存最多 100 组、每组三个已选择的真实候选,不记录整段正文。私密输入始终遮盖、不学习、不保存;切到其他应用时清空私密编辑状态。
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# RTOS 1.2.3 联想和刷新验收
|
||||
|
||||
2026-11-14,版本1.2.3/10203。完整匹配、未完成拼音补全、较短前缀分层,结合词频与光标前中文上下文排序。续写最多8项、上下文最多4个连续汉字;预测分片最大7163字节,沿用4分片/90000字符缓存预算,原始104,837组索引及163,238候选保留。隐私输入不提供上下文,原本可选的主动候选学习保持不变。
|
||||
|
||||
53项引擎和42项UX脚本断言通过,包含三模式补全、你好后续写世界、空格及换行结束联想且不自动采纳、候选加载期间不清空、不允许旧候选误提交、隐藏后的异步隔离和密码上下文隔离。厂商gx v0.10.1/3ed38835、8192KiB内存,普通/QuickApp圆方四配置构建通过。
|
||||
|
||||
本次原生模拟器启动被已占用的Inspector14201拒绝,未接管或终止他人模拟器,未把历史24视觉状态/84交互结果记为本版结果。RTOS真机、原生视觉、长期功耗和商店审核尚未验收。
|
||||
@@ -7,6 +7,40 @@ T9 = {letter: str(i + 2) for i, group in enumerate(GROUPS) for letter in group}
|
||||
GROUPS14 = ['qw','er','ty','ui','op','as','df','gh','jk','l','zx','cv','bn','m']
|
||||
KEY14 = {letter: '0123456789abcd'[i] for i, group in enumerate(GROUPS14) for letter in group}
|
||||
|
||||
def predictive_bucket(value):
|
||||
result=0
|
||||
for ch in value:result=(result*31+ord(ch))&0xffff
|
||||
return format(result%256,'02x')
|
||||
|
||||
def associations(entries,frequencies):
|
||||
"""Derive bounded word continuations from licensed phrases; no user corpus."""
|
||||
result={}
|
||||
for word in {word for words in entries.values()for word in words}:
|
||||
frequency=frequencies.get(word,0)
|
||||
if frequency<20 or not re.fullmatch(r'[\u3400-\u9fff]{2,12}',word):continue
|
||||
for cut in range(1,min(4,len(word)-1)+1):
|
||||
prefix,suffix=word[:cut],word[cut:]
|
||||
result.setdefault(prefix,{})[suffix]=round(frequency/(1+.25*len(suffix)))
|
||||
# Small, original conversational defaults supplement dictionary word splits.
|
||||
seeds=json.loads((APP/'src/common/association-seeds.json').read_text(encoding='utf-8'))
|
||||
for prefix,words in seeds.items():
|
||||
for rank,word in enumerate(words):result.setdefault(prefix,{})[word]=200000-rank*10000
|
||||
return {prefix:sorted(words.items(),key=lambda x:(-x[1],x[0]))[:8]for prefix,words in sorted(result.items())}
|
||||
|
||||
def completions(entries,frequencies):
|
||||
result={}
|
||||
for key,words in entries.items():
|
||||
for mode,code in [('py',key),('t9',''.join(T9[ch]for ch in key)),('14',''.join(KEY14[ch]for ch in key))]:
|
||||
for length in range(2,min(3 if mode!='14'else 2,len(code)-1)+1):
|
||||
if len(code)-length>4:continue
|
||||
target=result.setdefault(mode+':'+code[:length],{})
|
||||
for ordinal,word in enumerate(words):
|
||||
frequency=frequencies.get(word,0)
|
||||
if frequency<20:continue
|
||||
value=[word,key,frequency,ordinal]
|
||||
if word not in target or target[word][2]<frequency:target[word]=value
|
||||
return {prefix:sorted(words.values(),key=lambda x:(-x[2],len(x[1]),x[3],x[0]))[:12]for prefix,words in sorted(result.items())}
|
||||
|
||||
def from94(value):
|
||||
number = 0
|
||||
for ch in value:
|
||||
@@ -57,6 +91,11 @@ def convert(source, target):
|
||||
output=target/(name+'.json')
|
||||
data=json.dumps({'entries':content,'frequencies':weights}, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
|
||||
if not output.exists() or output.read_bytes()!=data:output.write_bytes(data)
|
||||
association_shards={};completion_shards={}
|
||||
for prefix,items in associations(entries,frequencies).items():association_shards.setdefault('a'+predictive_bucket(prefix),{})[prefix]=items
|
||||
for prefix,items in completions(entries,frequencies).items():completion_shards.setdefault('c'+predictive_bucket(prefix),{})[prefix]=items
|
||||
for name,content in {**association_shards,**completion_shards}.items():
|
||||
(target/(name+'.json')).write_text(json.dumps(content,ensure_ascii=False,separators=(',',':')),encoding='utf-8',newline='\n')
|
||||
# Every original candidate remains accessible, in original per-key order.
|
||||
restored = {};restored14={}
|
||||
for name in shards:
|
||||
@@ -69,6 +108,8 @@ def convert(source, target):
|
||||
t9_shards=sum(not name.startswith('d')for name in shards),
|
||||
key14_shards=sum(name.startswith('d')for name in shards),key14_groups=GROUPS14,key14_prefix_length=3,
|
||||
largest_shard_bytes=max((target/(name+'.json')).stat().st_size for name in shards),
|
||||
association_shards=sorted(association_shards),completion_shards=sorted(completion_shards),
|
||||
predictive_largest_shard_bytes=max((target/(name+'.json')).stat().st_size for name in [*association_shards,*completion_shards]),
|
||||
source_files={name:hashlib.sha256(data).hexdigest() for name,data in sorted(files.items())})
|
||||
if frequency_path.exists():report['t9_frequency_source']={'project':'jieba 0.42.1','license':'MIT','sha256':hashlib.sha256(frequency_path.read_bytes()).hexdigest()}
|
||||
output=target/'meta.json';data=(json.dumps(report, ensure_ascii=False, indent=2)+'\n').encode('utf-8')
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"你好": ["世界", "呀", "啊"],
|
||||
"谢谢": ["你", "大家", "支持"],
|
||||
"不客": ["气"],
|
||||
"早上": ["好"],
|
||||
"晚上": ["好"],
|
||||
"晚安": ["啦"],
|
||||
"再": ["见"],
|
||||
"生日": ["快乐"],
|
||||
"新年": ["快乐"],
|
||||
"今天": ["天气", "下午", "晚上"],
|
||||
"明天": ["见", "早上", "下午"],
|
||||
"天气": ["很好", "不错"],
|
||||
"我": ["想", "要", "是", "们"],
|
||||
"我们": ["一起", "可以", "去"],
|
||||
"你": ["好", "的", "们"],
|
||||
"请": ["问", "稍等", "帮忙"],
|
||||
"没": ["关系", "问题"],
|
||||
"没有": ["问题", "关系"],
|
||||
"可以": ["的", "吗"],
|
||||
"知道": ["了"],
|
||||
"收到": ["了"],
|
||||
"我爱": ["你"],
|
||||
"人工": ["智能"],
|
||||
"中国": ["人民", "文化"],
|
||||
"北京": ["大学", "天气"],
|
||||
"正在": ["输入", "加载"],
|
||||
"输入": ["法", "文字"],
|
||||
"手表": ["应用", "设置"],
|
||||
"周末": ["快乐", "愉快"],
|
||||
"工作": ["顺利"]
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export function digitsOf(text) {
|
||||
return result
|
||||
}
|
||||
export function chars(text){return Array.from(String(text||''))}
|
||||
export function contextTail(text){const match=String(text||'').match(/[\u3400-\u9fff]{1,4}$/);return match?match[0]:''}
|
||||
function predictiveBucket(value){let result=0;for(let i=0;i<value.length;i++)result=(result*31+value.charCodeAt(i))&65535;return (result%256+256).toString(16).slice(1)}
|
||||
// Four columns and four row units. Phrases take more width/height, never ellipsis.
|
||||
export function candidatePages(candidates) {
|
||||
const pages=[];let rows=[],usedRows=0,row=null
|
||||
@@ -93,15 +95,28 @@ export function createDictionary(read) {
|
||||
})()
|
||||
try{return await pending[name]}finally{delete pending[name]}
|
||||
}
|
||||
async function metadata(){if(!manifest){const meta=await read('/assets/ime-dict/meta.json');manifest=typeof meta==='string'?JSON.parse(meta):meta}return manifest}
|
||||
async function predict(context){
|
||||
const tail=contextTail(context);if(!tail)return []
|
||||
const meta=await metadata(),result=[],seen={}
|
||||
for(let length=tail.length;length>0&&result.length<8;length--){
|
||||
const prefix=tail.slice(-length),name='a'+predictiveBucket(prefix)
|
||||
if(!meta.association_shards||meta.association_shards.indexOf(name)<0)continue
|
||||
const rows=(await load(name))[prefix]||[]
|
||||
for(const row of rows)if(!seen[row[0]]){seen[row[0]]=true;result.push({word:row[0],pinyin:'',consume:0,frequency:row[1]});if(result.length===8)break}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return {
|
||||
async lookup(raw,t9,preferences) {
|
||||
predict,
|
||||
async lookup(raw,t9,preferences,context) {
|
||||
const input=String(raw||'').toLowerCase()
|
||||
const dual=t9==='14',ambiguous=!!t9
|
||||
if(!(dual?/^[0-9a-d]{1,32}$/:t9?/^[2-9]{1,32}$/:/^[a-z]{1,32}$/).test(input))return []
|
||||
const digits=ambiguous?input:digitsOf(input)
|
||||
const merged={},frequencies={}
|
||||
function merge(data){Object.assign(merged,data.entries);Object.assign(frequencies,data.frequencies)}
|
||||
if(!manifest){const meta=await read('/assets/ime-dict/meta.json');manifest=typeof meta==='string'?JSON.parse(meta):meta}
|
||||
await metadata()
|
||||
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))
|
||||
@@ -112,13 +127,32 @@ export function createDictionary(read) {
|
||||
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})}
|
||||
if(!seen[identity]){seen[identity]=true;result.push({word,pinyin:key,consume:key.length,frequency:frequencies[word]||0})}
|
||||
}
|
||||
if(input.length>=2){
|
||||
const mode=dual?'14':t9?'t9':'py',prefix=mode+':'+input,extras=[]
|
||||
if(input.length<prefixLength){
|
||||
const name='c'+predictiveBucket(prefix)
|
||||
if(manifest.completion_shards&&manifest.completion_shards.indexOf(name)>=0){
|
||||
for(const row of (await load(name))[prefix]||[])extras.push({word:row[0],pinyin:row[1],frequency:row[2],missing:row[1].length-input.length,consume:input.length,completion:true})
|
||||
}
|
||||
}else for(const key of Object.keys(merged)){
|
||||
const code=dual?code14Of(key):t9?digitsOf(key):key
|
||||
if(code.length<=input.length||code.length>input.length+4||code.indexOf(input)!==0)continue
|
||||
for(const word of merged[key])extras.push({word,pinyin:key,frequency:frequencies[word]||0,missing:key.length-input.length,consume:input.length,completion:true})
|
||||
}
|
||||
extras.sort((a,b)=>b.frequency-a.frequency||a.missing-b.missing)
|
||||
let added=0
|
||||
for(const item of extras){const identity=item.word+'@'+item.consume;if(!seen[identity]){seen[identity]=true;result.push(item);if(++added===12)break}}
|
||||
}
|
||||
const preferenceKey=dual?'k14:'+input:input
|
||||
const prefs=preferences&&Array.isArray(preferences[preferenceKey])?preferences[preferenceKey]:[]
|
||||
const rank=item=>{const i=prefs.findIndex(x=>x.word===item.word&&x.consume===item.consume);return i<0?100:i}
|
||||
// Rank only real dictionary results: a damaged preference cannot inject text.
|
||||
return result.map((item,index)=>({item,index})).sort((a,b)=>rank(a.item)-rank(b.item)||(ambiguous?(b.item.consume-a.item.consume||(frequencies[b.item.word]||0)-(frequencies[a.item.word]||0)):0)||a.index-b.index).map(x=>x.item)
|
||||
const predictions=await predict(context),contextRank=item=>{const i=predictions.findIndex(x=>x.word===item.word);return i<0?0:8-i}
|
||||
const category=item=>item.completion?1:item.consume===input.length?0:2
|
||||
// Preserve every exact candidate; complete unfinished syllables before
|
||||
// shorter fallbacks, and use word frequency plus the editor's local context.
|
||||
return result.map((item,index)=>({item,index})).sort((a,b)=>rank(a.item)-rank(b.item)||category(a.item)-category(b.item)||b.item.consume-a.item.consume||contextRank(b.item)-contextRank(a.item)||(b.item.frequency||0)-(a.item.frequency||0)||a.index-b.index).map(x=>x.item)
|
||||
},
|
||||
clear(){cache.length=0;manifest=null},
|
||||
get cacheSize(){return cache.length}
|
||||
|
||||
@@ -3,16 +3,17 @@ export function normalizeTheme(value) { return value === 'blackgreen' ? 'blackgr
|
||||
export function themeStyles(value) {
|
||||
const dark = normalizeTheme(value) === 'blackgreen'
|
||||
const c = dark
|
||||
? {bg:'#000000',card:'#1C1C1E',raised:'#2C2C2E',text:'#F2F7FA',muted:'#B3B3B8',dim:'#A0A0A7',accent:'#00C777',user:'#0E5C44',line:'#3C3C40',danger:'#FF6978'}
|
||||
? {bg:'#000000',card:'#1C1C1E',raised:'#2C2C2E',text:'#F2F7FA',muted:'#B3B3B8',dim:'#707075',accent:'#00C777',user:'#0E5C44',line:'#3C3C40',danger:'#FF6978'}
|
||||
: {bg:'#ECEEF2',card:'#FFFFFF',raised:'#F8F9FB',text:'#20232B',muted:'#737984',dim:'#737984',accent:'#2867D9',user:'#2867D9',line:'#D9DDE4',danger:'#B5424C'}
|
||||
const ink='color:'+c.text+';',muted='color:'+c.muted+';'
|
||||
return {
|
||||
surface:'background-color:'+c.bg+';'+ink,
|
||||
card:'background-color:'+c.card+';'+ink,
|
||||
raised:'background-color:'+c.raised+';'+ink,
|
||||
cyan:'color:'+(dark?'#4DB5FF':'#2867D9')+';',
|
||||
ink,muted,dim:'color:'+c.dim+';',accent:'color:'+c.accent+';',
|
||||
primary:'background-color:'+c.accent+';color:'+(dark?'#000000':'#FFFFFF')+';',
|
||||
secondary:'background-color:'+c.card+';color:'+c.accent+';',
|
||||
secondary:'background-color:'+c.raised+';'+ink,
|
||||
user:'background-color:'+c.user+';color:#FFFFFF;',white:'color:#FFFFFF;',
|
||||
input:'background-color:'+c.card+';'+ink+'border-bottom:2px solid '+c.line+';',
|
||||
plain:'background-color:transparent;'+ink,
|
||||
|
||||
@@ -56,14 +56,14 @@
|
||||
import storage from '@system.storage'
|
||||
import router from '@system.router'
|
||||
import {normalizeTheme, themeStyles as stylesForTheme} from '../common/ime-theme.js'
|
||||
import {createDictionary,chars,candidatePages,edit,erase,preview,learn,sanitizePreferences,KEY14_GROUPS,display14} from '../common/ime-engine.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,text:'',cursor:0,composition:'',compositionDisplay:'',display:'│',count:0,limit:2048,mode:'t9',modeLabel:'九宫拼音',upper:false,symbolPage:0,panel:'keys',keyRows:[],layoutRows:[],keyStyle:'',labelStyle:'',bar:[],visibleCandidates:[],candidateRows:[],total:0,candidatePage:0,pageCount:1,busy:false,hint:'离线全量词库',learning:false,clearArmed:false},
|
||||
dictionary:null,preferences:{},allCandidates:[],pages:[],active:true,life:null,requestId:0,lookupTimer:null,repeatTimer:null,repeatDelay:null,result:null,original:'',
|
||||
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'
|
||||
@@ -74,12 +74,14 @@
|
||||
this.mode=this.inputType==='number'||this.inputType==='pin'?'num':this.inputType==='url'||this.inputType==='password'?'en':'t9'
|
||||
this.renderKeys();this.updateDisplay()
|
||||
},
|
||||
onShow(){this.active=true;this.life.active=true;if(this.composition)this.schedule()},
|
||||
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(){this.count=chars(this.text).length;this.display=preview(this.text,this.cursor,this.secret);this.compositionDisplay=this.mode==='14'?display14(this.composition):this.composition},
|
||||
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-'])
|
||||
@@ -107,37 +109,40 @@
|
||||
},
|
||||
schedule(){
|
||||
this.compositionDisplay=this.mode==='14'?display14(this.composition):this.composition
|
||||
this.invalidate();this.allCandidates=[];this.bar=[];this.visibleCandidates=[];this.candidateRows=[];this.pages=[];this.total=0;this.pageCount=1;this.candidatePage=0
|
||||
if(!this.composition||!this.active)return
|
||||
const id=this.requestId;this.busy=true;this.hint='正在查词…'
|
||||
this.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
|
||||
try{const result=await this.dictionary.lookup(raw,this.mode==='14'?'14':this.mode==='t9',this.learning?this.preferences:{})
|
||||
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.allCandidates=result;this.total=result.length;this.bar=result.slice(0,3);this.pages=candidatePages(result);this.pageCount=Math.max(1,this.pages.length);this.pageCandidates();this.hint=result.length?result.length+' 个候选':'暂无匹配 · 可退格修改'
|
||||
}catch(error){if(life.active&&id===life.id)this.hint='词库暂不可用,可切换英文输入'}
|
||||
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.allCandidates.some(x=>x.word===item.word&&x.pinyin===item.pinyin&&x.consume===item.consume))return
|
||||
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(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}}
|
||||
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()}},
|
||||
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(' ')},
|
||||
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()},cursorRight(){this.cursor=Math.min(chars(this.text).length,this.cursor+1);this.updateDisplay()},cursorStart(){this.cursor=0;this.updateDisplay()},cursorEnd(){this.cursor=chars(this.text).length;this.updateDisplay()},
|
||||
newline(){if(this.allowNewline&&this.inputType!=='url'&&this.inputType!=='number'&&!this.secret)this.insert('\n');else this.hint='此输入项不支持换行'},
|
||||
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='学习清除失败,请稍后重试'}},
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"package": "com.sydxky.rtos.inputmethod",
|
||||
"name": "蟹蟹输入",
|
||||
"icon": "/assets/icon.png",
|
||||
"versionName": "1.2.2",
|
||||
"versionCode": 10202,
|
||||
"versionName": "1.2.3",
|
||||
"versionCode": 10203,
|
||||
"features": [],
|
||||
"router": {
|
||||
"entry": "main",
|
||||
|
||||
@@ -30,6 +30,7 @@ p.choose({...hello});check(p.text==='你好'&&!p.composition,'candidate event pa
|
||||
p.finish();check(closed.at(-1).accepted&&closed.at(-1).text==='你好','finish returns text')
|
||||
p=page({initial:'unchanged'});p.insert('!');p.cancel();check(closed.at(-1).accepted===false,'cancel does not return edited text')
|
||||
p=page({inputType:'password',initial:'😀secret'});p.toggleLearning();check(!p.learning&&!p.display.includes('secret'),'password masked and cannot learn');p.onHide();check(!p.text&&!p.initial&&!p.original,'password clears on hide')
|
||||
p=page({inputType:'password',revealSecret:true,initial:'Visible123!'});check(p.display.includes('Visible123!')&&p.secret&&!p.learning,'explicit password reveal preserves private input');p.toggleLearning();check(!p.learning,'revealed password cannot enable learning');p.onHide();check(!p.text&&!p.initial&&!p.original,'revealed password clears on hide')
|
||||
p=page({initial:'你😀好'});p.cursor=2;p.backspace();check(p.text==='你好','UI Unicode deletion')
|
||||
p.startDelete();check(p.repeatDelay!==null,'delete hold delay starts');p.onHide();check(p.repeatDelay===null&&p.repeatTimer===null,'hide stops repeat timers')
|
||||
p=page();let resolve;p.dictionary={lookup:()=>new Promise(yes=>resolve=yes)};p.composition='64';const pending=p.query(p.requestId);p.onHide();resolve([{word:'你',consume:2,pinyin:'ni'}]);await pending;check(p.total===0,'late dictionary response cannot update hidden page')
|
||||
@@ -43,4 +44,15 @@ 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();p.mode='py';p.composition='niha';await p.query(p.requestId)
|
||||
p.choose(p.bar[0]);p.invalidate();await p.query(p.requestId)
|
||||
check(p.text==='你好'&&p.prediction&&p.bar[0].word==='世界','partial spelling commits once and offers context continuation')
|
||||
p.choose(p.bar[0]);check(p.text==='你好世界'&&!p.composition,'continuation appends only the next word')
|
||||
p=page();p.mode='py';p.composition='shi';await p.query(p.requestId)
|
||||
const steadyBar=p.bar,steadyText=p.display;p.composition='shij';p.schedule()
|
||||
check(p.busy&&p.bar===steadyBar&&p.display===steadyText,'pending lookup retains candidate and preview nodes instead of clearing visible text')
|
||||
const beforeStale=p.text;p.choose(steadyBar[0]);check(p.text===beforeStale,'retained candidates cannot commit while their replacement is pending')
|
||||
p=page({initial:'你好'});p.composition='';await p.query(p.requestId);p.space();check(p.text==='你好 '&&!p.text.includes('世界')&&!p.prediction&&!p.bar.length,'space ends the prediction context without auto-committing or leaving stale suggestions')
|
||||
p=page({initial:'你好'});p.composition='';await p.query(p.requestId);p.newline();check(p.text==='你好\n'&&!p.prediction&&!p.bar.length,'newline ends the prediction context and clears stale suggestions')
|
||||
p=page({inputType:'password',initial:'你好'});check(p.inputContext()==='','password content is never used as predictive context')
|
||||
console.log(JSON.stringify({suite:'ime-client',assertions:count,status:'passed'}))
|
||||
@@ -56,4 +56,14 @@ 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')
|
||||
dictionary.clear();check(dictionary.cacheSize===0,'cache release')
|
||||
check((await dictionary.lookup('niha',false,{}))[0].word==='你好','unfinished pinyin completes a real word before shorter fallback')
|
||||
check((await dictionary.lookup(digitsOf('niha'),true,{}))[0].word==='你好','unfinished T9 shares the completion policy')
|
||||
check((await dictionary.lookup(code14Of('niha'),'14',{})).some(x=>x.word==='你好'&&x.consume===4&&x.completion),'14-key completion consumes only the actual input tokens')
|
||||
check((await dictionary.lookup('beij',false,{}))[0].word==='北京','word frequency places common completion before rare homophones')
|
||||
const continuation=await dictionary.predict('你好')
|
||||
check(continuation[0].word==='世界'&&continuation.every(x=>x.consume===0)&&continuation.length<=8,'post-commit continuations use the longest context with bounded results')
|
||||
check((await dictionary.predict('你好。')).length===0&&(await dictionary.predict('secret123')).length===0,'punctuation and non-Chinese contexts end prediction')
|
||||
check((await dictionary.predict('前文你好'))[0].word==='世界','prediction uses only the bounded suffix before the cursor')
|
||||
check((await dictionary.lookup('shijie',false,{}))[0].word==='世界','common full-pinyin homophone is ranked by licensed word frequency')
|
||||
check(dictionary.cacheSize<=4,'prediction and completion retain the bounded cache')
|
||||
console.log(JSON.stringify({suite:'ime-engine',assertions:count,keys:meta.keys,candidates:meta.candidates,status:'passed'}))
|
||||
@@ -50,6 +50,8 @@ def main():
|
||||
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');")
|
||||
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');")
|
||||
|
||||
在新工单中引用
屏蔽一个用户