Commit Graph
722 Commits
Author SHA1 Message Date
fanlusky 1ea01d5e0a fix(quanpin): 备选切分保护位只拉首页外的候选,不覆盖调频名次
保护位原来对任何排在 index 1 之后的备选切分都生效,于是调频刚把 吉安 排到
自然第 5 位,它就因为成了 ji'an 组里权重最高的词、过了 1/100 门槛而被重新
钉回第 2 位。用户选一次就跳到第二,之后再怎么调也只能停在第二——固定槽位把
调频写进权重的结果整个覆盖掉了。

改成只对自然排序已经掉出首页(默认 page_size 6)的备选切分生效。西安 在出货
词库里的自然位置是 16,仍旧提升到第 2 位;调频过的 吉安 留在它自己挣来的位置
上,之后按 promote 一步步往上走。

夹具同步补候选:engine 与 server 的 xian 夹具原来只有三条主候选,备选切分自然
就在首页内,测不到提升这条路径。
2026-09-19 22:42:29 +08:00
fanlusky e32eeadec9 fix(engine): 调频比较集跨键合并,按权重而非显示顺序取基准
打 jian 时列表里混着另一种切分 ji'an 的词。吉安 在出货词库里权重是 1,压在同组的
积案(9420)/几案(5999) 底下,而 adjust_candidate_ranking 按音节数圈比较集,于是
吉安 只跟 ji'an 这一组比,可学权重封顶一万出头,永远追不上 见 的 3460998——用户
选多少次,这个候选都纹丝不动。

音节数这个尺度是 #400 加的,为的是挡住另一个 bug:显示列表里备选切分词会被保护位
顶到最前面,中点数学却假定列表按权重降序,于是拿 西鄂(w=6) 当基准把 写 写成 506。
根子在「显示顺序 != 权重顺序」,按音节数滤掉跨表行只是绕开了症状,代价是把每个候选
关进自己的组里。

改成在比较集上直接按权重降序排一次,不变式在源头恢复:任何行都不可能坐在高于自身
权重的位置上充当基准,西鄂 那条路被堵死;同时比较集不再按音节数切开,吉安 的基准
取到整个列表最重的 见,置顶一次即越过,promote 模式连选五次一路走到首位。写入仍只
落在 entry_key 的行上,#36 的护栏不变。

test_user_dictionary_journal 新增两条回归(跨键置顶、连续 promote 收敛到首位),
#400 的两条断言原样保留并仍然通过。
2026-09-19 22:14:57 +08:00
fanlusky 663f72301d fix(server): 独立上屏的整句候选也落库
上一个提交改的是 InputSession::commit(),而 Windows 前端根本不走那条路:选词由
ProcessSelectionKey 自己驱动。那里 CandidateSource::Generated 走提前返回,落库的
闸门 ShouldStoreEarlyReturnPhrase 要求造词前缀非空,也就是只认"先选前缀、再用整句
收尾"这一种形状;用户直接选一条完整的整句时 creating_word 没激活,条件为假,什么
也不做就返回了。Google 解码器那条整句(Fallback)不在提前返回的名单里,走普通
路径,但同样没有行可以调频。

因此拆出 ShouldStoreStandaloneSentence:整句候选独立上屏时就该落库,带 7 音节
上限(对标 quanpin::WordLatticeOptions::max_phrase_syllables)。接在造词前缀
后面的那一支由用户一段段选出来,维持原样不设限。

顺带修掉同一处的读音拼接:前缀为空时不能带上那个分隔符,'na'yi'tiao 这种前导
撇号会让整条读音作废。

原先有一条测试把这个缺陷钉住了(断言没有前缀就不落库),按新的意图翻过来。
2026-09-19 13:48:26 +08:00
fanlusky ccbaa3a631 fix(engine): 送进 Google 解码器前把 ü 换成 nue/lue/ju 写法
小鹤双拼 ntdddswu 出的是「女蛾黛动物」而不是「虐待动物」:nt 转成全拼后
是 nve(shuangpin_pinyin_set 把 nue 抹掉了,词库与词格那一侧统一用 v 形),
而本地 googlepinyinime-rev 的拼写表和 inputtools 云输入都不认 nve/lve/jv,
只有 nue/lue/ju/jue,也不做 v→ü 的转写。带分隔符的串照样送过去了,解码器
却把 nve 重新切成 nv + e,于是有了「女蛾」。

加一个 quanpin::to_google_spelling,按音节整体改写 nve→nue、lve→lue、
jv→ju 这一类拼写,只在两个 Google 解码器的边界上用:云输入的 query_text、
双拼与全拼的整句回退。词库查询和词格用的仍然是 canonical_syllable 那一侧的
v 形写法,没有动。nv'er、lv'se 这些 Google 也认的写法原样通过。
2026-09-19 12:29:01 +08:00
Lu Fan(SonnyCalcr) e1d53dd8f0 Merge pull request #426 from ouchihao/codex/fix-299-double-helpcode-cache
fix(engine): isolate ordered double-helpcode query caches
2026-09-18 21:34:50 +08:00
Ouchihao 0925937bfe fix(engine): isolate ordered double-helpcode query caches 2026-09-18 20:43:17 +08:00
Ouchihao 5e641fb5a0 fix(engine): accept yo as a complete shuangpin syllable 2026-09-18 20:35:14 +08:00
Lu Fan(SonnyCalcr) 4dea1cd12f Merge pull request #420 from metasequoiaime/feat/ctrl-arrows-segment-caret
feat(input): 组词内按分词的光标与删除(#35 / #187 / #347)
2026-09-18 12:35:52 +08:00
Jia Sui 809f700998 test(server): 覆盖微软双拼 ';' 尾键的分段边界
- nihkb;(你好病 = ni|hk|b;)断言 {0,2,4,6}:';' 归属它开始的音节
- nihcb; 断言 {0,2,3,5,6}:贪心最长配对是契约,'cb' 是有效微软编码
  (cou),按段删除跟随 preedit 显示的同一条切分

Refs #187
2026-09-18 08:59:19 +08:00
Jia Sui 2576986881 feat(windows): Ctrl+左右键按分词移动光标
组词中 Ctrl+Left/Right 按一个字的拼音(音节段)移动光标,
与 Ctrl+Backspace 的删除粒度共用同一套引擎分段边界。

- 闸门归纳为 SegmentEditFunction(code, modifiers):Ctrl-only + 组词存在,
  VK_BACK/LEFT/RIGHT 分别映射到 segment 函数;三处判据与延迟投影共用
- 新增 FUNCTION_MOVE_LEFT_SEGMENT / FUNCTION_MOVE_RIGHT_SEGMENT,
  编辑会话只按 CompositionRestored 的 caret 移动本地光标,不重建 buffer
- Server 用 segment_raw_boundaries() 取前/后边界,能力位与非 UiLess 时回包;
  非拼音/本地模式/UiLess/旧端仍按单字符 ±1 回退

Refs #187
2026-09-17 23:36:34 +08:00
Jia Sui b1ec3ab1e0 feat(server): Ctrl+Backspace 按分词删除前置拼音(#187)
组词中 Ctrl+Backspace 删除光标前的一个分词单元:剩余拼音非空时删
最后一个字的拼音(音节段),剩余拼音为空时删除最后一段已选分词。

engine:
- InputSession::segment_raw_boundaries() 作为按段删除/移动的唯一分段来源:
  全拼把纠错显示串的分隔符映回 raw 下标,双拼与 pinyin_segmentation 共用贪心谓词,
  2 字辅助码自成一段;非拼音方案与本地模式返回空表示无单位模型
server:
- IsSegmentBackspaceKey(Ctrl-only)、整段丢弃 / 边界截断 / 单字符回退三分支,
  截断点残留分隔符清理
- 复用 CompositionRestored 回包(含 caret),仅协商能力位且非 UiLess 时发送;
  新出现「只剩已选汉字、无拼音」的中间态
windows:
- 两处 Ctrl 闸门对组词中的 Ctrl+VK_BACK 放行,新增 FUNCTION_BACKSPACE_SEGMENT
  与 _HandleCompositionBackspaceSegment(读回包重建,失败回退单字符)
- 空 raw 状态渲染、终态清理 word_for_creating_word,延迟投影失效处理

Closes #187
2026-09-17 23:01:31 +08:00
fanlusky bc37ac9a9e feat(installer): 安装时可自定义数据目录位置
词库、用户配置和皮肤此前固定在 %LOCALAPPDATA%\metasequoiaime,整包几百 MB
全压在 C 盘。安装向导新增一页,允许把这部分数据放到任意盘。

程序本体仍固定装在 Program Files:MetasequoiaImeServer.exe 带 uiAccess=true,
只有在受信任目录里这个标志才生效。

运行期的权威来源是 HKLM\Software\Metasequoia\MetasequoiaIME 的 DataDir。
Server、TSF DLL 和引擎三个组件各自按同一顺序解析:METASEQUOIA_IME_DATA_DIR
环境变量 -> 该注册表值 -> %LOCALAPPDATA%\metasequoiaime。三处各持一份实现,
因为组件边界规则禁止 windows/ 与 server/ 互相 include。32 位 TSF DLL 用
KEY_WOW64_64KEY 读,避开 WOW64 重定向。

安全相关的两个约束:

- 安装器在数据目录里写 .metasequoiaime-data 标记。覆盖安装的清理和卸载的整目录
  删除只在看到这个标记(或目录就是历史默认位置)时才执行——用户可能把数据目录
  指到一个本来就有自己文件的文件夹。
- 选择页与 /DATADIR= 走同一套校验:拒绝相对路径、盘符根目录、不存在的盘、程序
  目录内部,以及 Windows/Program Files/各 AppData/用户主目录本身。

升级时改了位置,PrepareToInstall 在停掉输入法进程后用 robocopy /MOVE 搬运用户
拥有的数据(msime_user.db 及其 WAL/SHM/journal、config.toml、config.base.toml、
skins\),词库和前端资源由本次安装重新写入。跨卷移动目录 MoveFile 会失败,所以用
robocopy。用户词库搬运失败会中止安装:否则新版本对着空的 msime_user.db 启动,
ssPostInstall 的回放只记一句 skipped,用户的自造词就静悄悄没了。

测试侧新增 METASEQUOIA_IME_CONFIG_DIR,只改 config.toml 的位置而不搬词库。原先
靠重定向 LOCALAPPDATA 做隔离的办法在装了正式版的机器上已不再成立——注册表值
的优先级高于它。
2026-09-17 22:49:19 +08:00
fanlusky a898c3b11c feat(candidate): Ctrl+Enter 上屏候选右侧的译文
候选框开着且高亮候选带译文时,Ctrl+Enter 上屏那条译文而不是候选本身。
译文只有一条就直接上屏;有多条时把候选框整个换成这几条译义,空格/数字键
照常选一条上屏。

TSF 端原先在分类器最前面把所有 Ctrl 组合都还给应用,Ctrl+Enter 到不了
Server。现在在那条拦截之前先判一次:候选框开着才认,否则组合仍归应用。
认下来的键走既有的 FUNCTION_SERVER_CANDIDATE_KEY 通道,由 Server 决定
是上屏(CommitExactText)还是只开一个新候选页(NavigationIgnored)。

译文页复用同一份 candidate_ui.items,因此云/AI/英文/emoji/颜文字这些异步
回调和候选扩展都要在这个子模式下让路,否则会把译文冲掉。输入串全程没动,
退出时把存下来的 items 和页码原样放回去即可。译文页上的空格/数字键只回
Normal / OutofRange——SELECT_BY_NUMBER 的 TSF 路径只认这两种。
2026-09-17 20:21:56 +08:00
Jia Sui e64195731e feat(server): Backspace 删空剩余拼音后退选已选分词(#35)
已选分词是造词中间态:删空剩余拼音时不再整条 composition 丢弃,
而是退选最后一段并恢复其原始拼音与候选。

engine:
- SelectionTransition 暴露本次选择消费掉的原始输入(保留大小写与双拼按键)
engine/contracts:
- 新增回复类型 CompositionRestored=14 与能力位 CompositionRestore;
  MaxKnown 同步抬高,Server 按协商能力位门控发送,旧 DLL 不会收到未知帧
server:
- 造词中间态压 (consumed_raw, previous_creating_word) 快照,边界 Backspace 弹栈退选
- 任何清空 raw 的路径同时清 creating_word 与快照历史,避免跨 composition 泄漏
windows:
- _HandleCompositionBackspace 提示退选时读回包重建 keystroke buffer / word / 预编辑 / 光标
- NeedToCreateWord 与 CompositionRestored 共用载荷解析;旧 Server 与 UiLess 回退旧行为

Closes #35
2026-09-17 18:45:01 +08:00
Jia Sui d4a0796400 feat(windows): 重构智能标点
原「智能标点」是小狼毫 digit_separators 的语义:字母或数字后的 , . : 直出英文,
2 秒内再按同一标点转回中文——后半段靠 SendInput 往应用里补一个 Backspace 再打
字符,落点全靠光标当时在哪。这次换掉主路径:输入中文标点后按空格,用 TSF 编辑
会话就地把它改写成对应英文(。,!?;:、 → . , ! ? ; : /),2 秒内再按同一标点
键撤回中文;原来的直出降为子开关,整个家族默认全关、改为显式开启。

- 按键与会话:转换/撤回是本地文档改写,不进 IPC、不进延迟队列;判据落在
  _IsKeyEaten,OnTestKeyDown 的预判与 _DispatchKeyDown 的第二遍预判共用同一份,
  声称吃掉的键不交还宿主。改写走编辑会话,请求不上时另起一个最小会话把键本身
  的字符写回去,避免已吃掉的键静默丢失。
- 状态:原来散落的九个成员收成一个 SmartPunctuationAction(None / ChineseCommitted
  / AsciiConverted),带焦点令牌、前台窗口和 beforeChar 指纹;清理以分类结果为准,
  丢焦点与上下文栈变化一并清掉。
- 转换范围:。,!?;:、 与单独出现的 “”‘’【】《》() 都是映射表里的单字符
  条目,路径完全一致;成对补全产生的一对(autoClosedPair)不 arm,按空格只落空格,
  不会留下 <》 这类孤儿。
- 开关:总开关 + 中文标点后按空格转换 + 同键转回中文 + 数字后直出 + 字母后直出,
  五键全部默认关;直出拆成数字/字母两个独立子开关(IPC 24 收窄为数字、25 追加为
  字母),TSF 按前字符类型分别查键。
- 命名:子开关「独立符号」改名「中文标点后按空格转换」,配置键
  smart_punctuation_standalone → smart_punctuation_space_convert,IPC 22 只改常量名
  (SmartPunctuationSpaceConvertChanged),编号与语义不变、新旧测试包互通;设置页
  文案同步重写,五处配置链路与死宿主一并跟改。
- 兼容:旧键未出厂、不迁移(升级合并按模板缺键丢弃、取默认 false);编号 23 随
  「按空格整体转换一对」的设计退役并留空不复用。
- 验证:TSF x86/x64 与 Server Release 编译、Server 335 单测、windows_ipc_contract
  x64/x86、设置页 build + 27 测试、clang-format 18.1.8 全绿。真机未跑。

Signed-off-by: Jia Sui <jsfaint@gmail.com>
2026-09-17 09:29:57 +08:00
fanlusky ef7840a4c6 fix(voice): 语音条水平按整屏居中,任意任务栏边缘都居中偏底部
上一版改用工作区(rcWork)定位后,任务栏在左/右边时工作区被挤窄,
水平中心会相对屏幕中心偏移,语音条不再位于屏幕正中。

明确目标行为:任何情况下都让语音条“屏幕水平正中、偏底部”。
 - 水平改回按整屏矩形(rcMonitor)居中,不受左/右任务栏影响;
 - 垂直仍贴工作区(rcWork)底部,自动避开底部任务栏。

把 GetMonitorWorkArea() 换成一次解析同一显示器、同时返回整屏与工作区的
GetForegroundMonitorMetrics(),供上述两个维度共用,避免两次解析落到不同
显示器。
2026-09-16 16:12:24 +08:00
fanlusky 7e493b6a6d fix(voice): 语音条按工作区定位,兼容任务栏在任意边缘
update_window_bounds() 此前用"整屏矩形 - 底部任务栏高度"来定位语音条,
隐含假设任务栏在底部。任务栏放到左/右/上边时,语音条会算偏(位置错误或
与任务栏重叠)。

改为使用显示器工作区(MONITORINFO.rcWork)——它已扣除任意边缘的任务栏/
应用栏,水平居中于工作区、贴其底部上方 10px 放置。任务栏在底部时行为不变
(rcWork.bottom == monitor.bottom - taskbar_height)。

顺带把只服务于旧定位方式的 GetTaskbarHeight() 与返回整屏矩形的
GetMonitorCoordinates() 替换为 GetMonitorWorkArea()(二者原本仅此一处使用),
并移除不再需要的 <shellapi.h>。
2026-09-16 16:07:37 +08:00
fanlusky 510f70c1a5 fix(voice): 语音条适配分辨率/DPI 切换——与候选框一致
语音提示条(WaveOverlay)在切换屏幕分辨率/DPI 后尺寸与缩放错乱,
候选框已有的适配逻辑此前未覆盖到语音这一块。根因有二:

1. update_dpi_scale() 用 GetDpiForWindow(hwnd_) 取缩放,而定位用的是前台
   窗口所在显示器(GetMonitorCoordinates),两者可能不同源,且切换分辨率
   时 GetDpiForWindow 可能返回陈旧值。改用 GetDpiForMonitor(MDT_EFFECTIVE_DPI)
   按目标显示器计算,与候选框 ScaleFromMonitor 同一套技术,并让定位矩形与
   缩放同源。
2. ID2D1HwndRenderTarget 的 DPI 只在创建时定过一次,切换后不更新,导致
   draw() 里逻辑坐标映射到错误像素尺寸。现在在 update_dpi_scale() 中同步
   刷新渲染目标 DPI(等价于候选框 SetDpiOverride/SetDpi)。

同时把 WM_DISPLAYCHANGE 从 WM_PAINT 分支拆出单独处理,并让
update_window_bounds() 每次重新定位都刷新缩放,对齐候选框"每次显示都
重算"的行为。
2026-09-16 15:49:19 +08:00
Jia Sui b149a500b0 fix(server): 按数字选词偶发选错——以屏幕上的候选列表为准
按数字 2 想选第 2 个词,有时上屏的却是第 1 个:后台按手里的最新候选列表
数序号结算,而用户看到的是窗口还没重画完的旧画面;调频(一次置顶)每次
选完都改顺序,两者的时间差就会撞出「看 2 选 1」。关掉调频或固定候选位置
后顺序不再变化,问题消失——与报障观察一致。

修法:候选列表带版本号,窗口画完后回报画到第几版;按数字或空格时如果
窗口还没画到最新,后台最多等 30 毫秒再按用户看到的那一版结算。等待上限
是硬约束:必须留在 TSF 50ms 回复窗口内,超时会触发管道重连与按键重放。
窗口不可见、宿主自绘(UILess)、鼠标点击不等待。

- 发布候选页时递增版本号,快照携带该值;
- D2D 绘制完成、WebView2 DOM 回调(含 dedup 分支)回写实际渲染快照的版本号,
  异步回调捕获快照值而非回读实时值;
- 单测覆盖等待判定与 30ms 常量;阴性验证:等待失效时「落后 → 等待」用例
  失败,恢复后 333 项测试全绿;
- 真机验证(调频 pin + 快速同拼音 + 连按数字)待跟进。
2026-09-15 11:02:39 +08:00
fanlusky 5a5dea6b7e feat(japanese): 日语模式禁用 -/= 翻页并用 - 打长音符
日语模式下把 - 让给长音符(ー)输入,同时禁掉 -/= 的候选翻页:

- 罗马字方案接受 VK_OEM_MINUS,set_raw_input 不再把 - 过滤掉;
  罗马字表里本来就有 - 到 ー 的条目,只缺按键准入。
- n 后面接 - 时按辅音处理,让前面的 n 收成 ん,避免 n- 卡在 pending。
- 空编码时单独按 - 也起头组合,候选框第一项是 ー、第二项是普通 -。
- 组词流程同样适用:判据只看日语模式加组合状态,不再走翻页和词转字。
- -/= 在日语模式下不翻页,_ = + 这些没有假名写法的字符退回标点上屏,
  并把 = _ 补进服务端的标点集合,和 TSF 侧保持一致,否则会等不到回包。

TSF 侧的影子分类、IsVirtualKeyNeed(ForFreshComposition) 和服务端按键策略
三处同时改,统一以配置项 input_mode == japanese 为判据(R 模式不在范围内,
TSF 侧看不到临时会话,用会话类型判断会让两侧分类打架)。
2026-09-15 01:57:50 +08:00
fanlusky 9e2aedc915 chore(settings): 调整设置启动页提示文案与字号
将启动动画页的副标题提示由「首次启动可能需要稍等片刻」改为
「冷启动可能需要稍等片刻」,并将该行字号由 13.0 DIP 加大到 14.0 DIP。
2026-09-15 00:27:58 +08:00
Jia Sui 9bbd86b633 fix(engine): 调频权重基准限定同音节尺度
置顶/提升把一个候选的权重算成「显示列表首位权重 + 500」。显示列表会混入
备选切分词和不同切分的候选,它们的权重是另一套尺度(单字表是语料计数、
多字表是词组词权),于是基准取错就把候选写成垃圾值:用户机器上 xi'e 的西鄂
(w=6) 坐在 xie 列表首位时,置顶写会写出 506(= 6+500),低于些/血/谢,反而
被埋到三十名开外;将也以同样方式被写成 24240(= 激昂 23740+500)。

- 候选的 rank 与权重基准改在同音节尺度内计算:同音节数的表互相可比(九宫格
  数字上下混多个单字表、简拼上下文混多个 key 都保留原语义),跨音节数不再
  互相充当中点基准。选中词已是同尺度首位时不再写权重,避免被跨尺度反降。
- 写入仍按 entry_key 走,rebalance 不会落到别的 key 的行上(#36 的护栏不变)。

test_user_dictionary_journal 增加回归:混表列表里置顶写必须以些为基准,
且不再把些本身写成 506。
2026-09-14 20:14:28 +08:00
Jia Sui 809c62995b fix(engine): 全拼模糊音下备选切分抢位
模糊音开启时 QuanpinDictionary::query 对整个候选列表按 pinyin 字符串长度降序
排序(本意是压住模糊/前缀候选)。备选切分的 canonical 键带撇号且音节更多
(xi'e / ji'ang / you'di'an),字符串比主切分键(xie / jiang / you'dian)更长,
于是权重只有 6 的西鄂、23740 的激昂从保护位第 2 跳到第 1,顶掉 xie/jiang 的
首候选;xie 连模糊变体都没有,仅因排序无条件执行就被波及。

- 排序键改为候选覆盖的输入字母数(去掉撇号),与用户实际敲的长度对齐:备选
  切分键与主键同层交给词频,模糊/前缀候选仍排在整串匹配之后。
- 备选切分保护位加词频门槛:备选最佳词权重达到主切分首位 1/100 以上才提升。
  真歧义保留(xian -> 西安 55K 对 先 1.66M),罕见重码被拒(xie -> 西鄂 6、
  jiang -> 激昂 23.7K、youdian -> 尤迪安 7)。

test_fuzzy_pinyin 补齐四个场景回归断言(夹具权重取自出货词库);
test_quanpin_scheme 的保护位用例移到新契约:夹具备选词权重 1 -> 100,
让它留在门槛内继续覆盖「真歧义保留保护位」。
2026-09-14 20:14:28 +08:00
Evan 30a22e6f3d feat(ipc): reserve dedicated voice control pipe 2026-09-14 19:04:20 +08:00
Evan b10b8184d3 fix(ipc): reject malformed voice control fields (#398) 2026-09-14 10:40:29 +00:00
Evan 6b33e678fb feat(ipc): dispatch validated voice lifecycle commands 2026-09-14 10:32:24 +00:00
Evan 497b46e067 feat(ipc): validate voice control leases
* feat(ipc): validate voice control leases

* style(ipc): format voice control dispatcher

* fix(ipc): use local engine contract path
2026-09-14 18:21:49 +08:00
Evan e31964ce4f feat(voice): expose serialized lifecycle controls 2026-09-14 10:01:18 +00:00
Jia Sui 3dea733f64 fix(ui,server): 候选窗 D2D 阴影按皮肤对齐 webview——Card 支持 pass 列表
D2D 候选窗的 Card 阴影是内置 Win11 漫射三层(σ11/6/2.8,α0.42/0.30/0.48):
四边对称、无偏移,紧贴边缘合成 α≈0.49、暗晕铺到 15~20 DIP。webview 端候选窗
皮肤 CSS 用的是带右下偏移的双层软阴影(light: 8px 10px 24px rgba(0,0,0,.18)
+ 2px 3px 8px rgba(0,0,0,.10))。用户实机截图剖面实测:webview 底边峰值 α≈0.16、
D2D 0.41,差 2.5 倍,即「D2D 阴影偏重」的根因。对称三层无论怎么调参都拟合不了
带偏移的衰减曲线;浮动工具栏已有的 SetShadowScale 只缩模糊半径压不了深度
(高斯在边缘恒贡献 α/2)。

给 Card 拆出数据驱动的阴影 API,阴影参数跟皮肤走(与颜色/圆角同模式):

- ui/include/msimeui/Layout.h、ui/src/Layout.cpp:ShadowPass{σ,α,offset} +
  SetShadowPasses(空列表保持内置 Mist 三层,逐位不变)、SetShadowEnabled
  (整体开关,语义对齐 Popup 同名 API)、SetShadowOpacity(uniform 缩 α,与
  SetShadowScale 正交);渲染统一走 DrawGaussianShadowPass,无 device context
  时兜底 DrawLayeredMistShadow
- server/src/window/candidate_presenter.cpp:ApplySkin 按皮肤设阴影——内置四皮肤
  与自定义包用皮肤 CSS 标准对(σ=blur÷2)light {12,0.18,(8,10)}+{4,0.10,(2,3)}、
  dark α .34/.22;默认皮肤 webview 端 body HTML 无 box-shadow,D2D 同步关掉
- 浮动工具栏(scale=0.45)与托盘菜单(全默认)不触新 API,观感不变
- 透明垫余量重验:新 pass 可见范围(α>0.02 阈值)左16/上14/右32/下34 DIP,
  均在左32/上20/右32/下40 垫内,无裁切切痕
2026-09-14 12:34:10 +08:00
fanlusky f05507ade8 fix(config): 升级时保住用户已填的 API token 等凭证
用户反映更新后 API token 被清空、需要重填。根因不在安装器(它早已用
onlyifdoesntexist 且清理时保留 config.toml / config.base.toml,从不覆盖用户
配置),而在 Server 启动时的合并路径 SyncConfigWithInstalledTemplate:当
config.toml 存在、非空但解析不过(上次写入被打断留下半截文件,或 token 里混入
破坏 TOML 的字符)时,旧逻辑直接拿出厂模板覆盖,等于把用户全部配置——尤其是
凭证——清零。

配置升级本质上已经是「逐条把用户值重放到新模板」(MergeTomlIntoTemplate),因此
不另建配置操作日志,而是把「逐条重放」精准用在最难重填的凭证上,并堵掉硬重置:

- ReapplyRealCredentials:合并结束时把用户填过真值的凭证逐条重放回去,有键改值、
  无键则在其分节插入。覆盖 token / api_key / apikey / secret_id / secret_key /
  app_id / asr_app_key 及所有 per-provider 槽位(token_* / asr_token[_*] /
  polish_token[_*]),真值判定复用 VoiceInput::IsPlaceholderToken。这样模板漂移
  漏掉某个 token 键也不会静默丢失;占位符不粘住,新默认值照常生效。
- SyncConfigWithInstalledTemplate 的 unparseable 分支:先把坏文件备份成
  config.toml.corrupt-<时间戳> 留证,再逐行抢救可识别赋值重放到新模板;即便整体
  抢救结果仍解析不过,也只回退到「模板 + 重放凭证」,而非纯模板。

新增回归测试:模板漏键时真 token 被写回、未填的占位符跟随新默认、损坏配置能捞回
真 token,以及读安装器真正分发的 config.default.toml 逐分节确认所有凭证键挺过升级。
2026-09-14 03:15:16 +08:00
fanlusky 529060a359 feat(settings): API Key 配置项增加连通性测试按钮
设置页中所有需要用户自填 API Key 的配置项(AI 助手、候选词翻译、
语音识别、语音润色)旁增加「测试配置」按钮,点击后用当前填写的凭据
向服务发一个最小请求,即时反馈配置是否有效。

- server: 新增 ApiCredentialTest::Run 统一分发(chat 发 1-token 请求、
  ASR 传静音 WAV、翻译发单词校验、豆包走 DoubaoAsrClient::TestCredentials),
  settings 可执行文件补上 curl/zlib/bcrypt/winhttp 及 cloud/voice 源。
- engine/contracts: 新增 apiCredentialTest / apiCredentialTestResult 消息,
  已 generate.py 重新生成并 sync-contracts 同步至 ui-html/shared。
- ui-html: credential-test 模块负责发消息与渲染结果;测试按钮改小两号;
  外观页补充字体下拉框加宽至 1.5 倍、加号按钮宽度减半。
- docs(agents): 补充格式化会卡 CI 的注意事项——clang-format 须为 18.1.8,
  且 format.sh 在 Windows 上因 venv 布局失效,需直接调 clang-format 自查。
2026-09-13 22:11:09 +08:00
fanlusky e350a94509 feat(translation): 候选词翻译支持小牛翻译(NiuTrans)
在线翻译服务由「腾讯云 / 自定义 DeepLX」扩展为三选一,新增小牛翻译。

- 新增 NiuTrans provider(/v2/text/translate):按官方规则对参数加 apikey
  排序拼接后取 MD5 作为 authStr(BCrypt 实现,无新增依赖),表单编码逐条
  翻译,解析 tgtText,errorCode/errorMsg 视为失败
- cloud_translation 调度改为 Provider 枚举,优先级 niutrans > custom > tencent,
  缓存 key 带 provider 区分
- 配置层新增 [niutrans](enabled/app_id/apikey)及读取与读写接口
- 设置界面新增「小牛翻译」下拉项与 APP ID / API Key 输入框,切换时保持
  provider 互斥
- 新增 test_niutrans_translation(凭据校验、鉴权 MD5、响应解析)
2026-09-13 20:54:45 +08:00
fanlusky 8097aa7df2 feat(voice): 豆包语音识别支持新版控制台单 API Key 鉴权
新版豆包控制台只签发一个 API Key,以 X-Api-Key 发送,不再需要 App ID。
原先这条路径是靠「asr_app_key 是否为空」隐式推断的,而随包配置里
asr_app_key 带有占位符(<YOUR_OWN_DOUBAO_APP_ID>)且非空,全新安装会被
判定为旧版控制台,带着占位符发送 X-Api-App-Key 导致鉴权失败。

改为显式配置项 voice_input.doubao_auth_mode(api_key | legacy):

- voice_providers 新增 NormalizeDoubaoAuthMode()/UsesDoubaoLegacyAuth(),
  集中处理模式解析。该项缺失时按 asr_app_key 是否为「可用值」推断,占位符
  不计入,因此既有配置升级后行为不变。
- DoubaoAsrClient 构造函数改收显式 legacy_auth,新版模式下忽略配置中残留
  的 App ID;连接失败提示按模式区分文案。旧版 App ID + Access Token 路径
  完整保留。
- 设置界面新增「豆包鉴权方式」下拉框,新版模式下隐藏 App ID 输入框并把
  Token 标签改为「API Key」。

同时把官方推荐用于输入法场景的 bigmodel_nostream 整句流式接口做成设置里的
可选项,与原有「接口地址」文本框双向同步。默认端点仍为 bigmodel_async,避免
改变既有用户的实时上屏行为。

新版文档将 payload_msg.result 描述为列表,而原 ParseResponse 只按对象读取,
接上该端点会安静地返回空转写。新增 ExtractTranscript() 同时接受对象与数组两
种结构,一套解析覆盖两个端点。
2026-09-13 18:58:39 +08:00
Jia Sui 94abc08e7c feat(engine): ü 系拼写别名归一,非标准拼法候选带轻标记
nue/lue 是词库标准键 nve/lve 的混拼别名,此前按原样查库必然落空。
把别名归一收敛到 resolve_segments 唯一咽喉点(jqxy 系 jv→ju 既有行为
一并收敛),查询、调频、造词键全部落到标准拼法上;别名改写同步进
pinyin_segmentation_,mark_autocorrect_candidates 据此给非标准拼法的
候选写 corrected_from,前端以词尾 * 轻标记提示(对标搜狗纠错星标),
提交、调频、置顶、造词不受影响。

契约翻转:fold_autocorrect_letters 不再把 v 折叠成 u——旧契约
「别名永不标记」按产品决策废弃,别名即标记;nu/nv、lu/lv 是真实
不同音节,归一按段精确匹配 [nl]ue,绝不互串,nu'e 手动分隔符不受
影响。composition 层显示折叠保留(职责是分隔符重建的等价分类),
注释改写说明与字典侧标记契约的分工。
2026-09-13 15:56:29 +08:00
Jia Sui 3c2f3ae374 feat(engine): 全拼纠错补齐漏字/多字,k-best 切分按词频消解歧义
此前纠错只有交换(sahng -> shang)与 QWERTY 邻键(shabg -> shang)两类,
生成器在离线阶段就把「一个错串对应多个音节」的变体整个丢掉,运行时也只
取代价最低的一条切分。三类输入因此没有出路:漏字的(shng)、多敲一键的
(shangg、sjhang)、漏声母连基础切分都切不出来的(uanli 想打 quan'li)。

本次按专利 CN 101133411 B 的思路重排整条链路:错串可能对应的读音全部
保留到运行时,先由 k-best 切分带出平行读音,再让词典词频决定谁排前面。

- 纠错类型从两类扩到四类:新增漏字(shng -> shang)与多字(shangg 双打、
  sjhang 邻键 -> shang)。两个新位跟随既有 autocorrect_transposition /
  autocorrect_neighbor 开关联动(任一打开即生效),定义保持独立,留待
  将来的独立配置键。查询与 preedit 重建共用同一映射,两侧纠正空间一致。
- 生成器从两表扩到四表并保留歧义变体(ahan -> {shan, zhan});同一条
  (错串, 正确) 对被两条规则产出时在两张表各留一份,保证每个类型开关
  单独有效。正确音节收敛进 kCorrectSyllables 单表、行内改存 16-bit
  下标,一万七千多行不再各自重复音节字符串,--check 拦截过期表头。
- 切分层新增 autocorrect_cut_kbest:按位置自左向右做 top-k 假设传播,
  排序键是(纠错边数,边权之和,生成序)——最少纠错优先的契约不变,
  权重只在同边数内打破平局,引擎绝不用一条额外纠错换更低权重。边权取
  专利 [0052] 允许的估常量(交换 10 / 漏字 11 / 多字 12 / 邻键 13),
  标定路径是挖掘用户日志。漏字/多字边不等长,切分段改按 raw 长度推进
  位置,preedit 分隔跟着原始字母的区间走。
- 词典层固定 k=9,为离线评测定值:漏声母键 uan 有 10-15 个等权漏字
  目标,k=3 按表序截断够不到 quan'li 的正确读音;k=9 提升 R@1/R@3
  且 p95 不变。主切分仍是查询键,其余读音交给
  merge_alternative_segmentations 与主读法在词频下竞争;备选读音在
  缓存命中路径同样发布,corrected_from 标记对全部纠错读音生效;纠错
  读音排在音形别名读音之前。结尾单插入(shangg = shang + g)天然是
  简拼尾形状,仍被简拼形守卫拦下——多字纠错的可达面在中间/开头插入
  与多音节输入。
- 纠错门放宽:!segments.empty() 从门里去掉。BFS 工作在原始字母上,
  基础切分为空的丢声母输入恰是最需要纠错的一批,jian -> ian 现在能
  走到纠错候选并按词频合并到首位。简拼形守卫保留:合法音节加至多一个
  尾字母(zheg)是用户意图,任何纠错表都不得改写。
- preedit 补两道防线:zheg 在显式三位掩码下仍走简拼守卫,不被漏字表
  重排为 zheng;只有 BFS 切分的读音与 scheme 别名层完全一致时才按
  BFS 位置重画分隔符,否则 sahng 会被切成 sa'hng 而候选实际来自
  别名读音 shang。

新增独立评测工具 eval_quanpin_autocorrect:从真实 msime.db 采样高频
词条,按交换/邻键/漏字/多字模型注错,测 R@1/R@3、读音还原率与温态
p50/p95,固定种子可复现。它是评测工具不是测试,永远不进 CI,作为
纠错阈值与门逻辑改动的零回归标尺;注错规则与生成器逐键一致,改任一
侧须同一提交同步另一侧。

表格测试改为按 (错串, 正确) 对断言唯一并加覆盖门(合计 >= 4700 条),
新增歧义保留、漏字/多字变体、k-best 排序与权重定序、preedit 守卫等
用例;engine/tests 的 README 与 AGENTS.md 同步写清 imetest 与评测
工具两个产物的边界。
2026-09-13 11:04:34 +08:00
Jia Sui 1ea42a31cb test(server): 模糊音播种标记缺键默认值补行为兜底
播种标记没有公开 getter,此前缺键形态只断言文件里没有该键,若有人把
value_or(false) 改成 value_or(true),现有用例全绿——所有播种用例的夹具
都拷贝含显式 fuzzy_seeded = false 的出厂模板,缺省值从不参与判定。缺键
即出厂态,补上开总开关必须播种的断言(位图 0x7ff + 标记落盘),变异
验证 value_or(true) 恰好在此红。
2026-09-13 09:43:22 +08:00
Jia Sui a3b1e4380c feat(server): 模糊音首次启用播种规则并持久化标记
总开关默认关不变;出厂态(fuzzy_seeded 未置位)第一次开总开关时,用一次
WriteConfiguredValues 批量写 13 键(总开关 + 11 规则全 true + 播种标记),
单文件原子写无部分失败态。标记置位后总开关任何翻动只写总开关一个键,
用户修剪过的选择在临时停用/恢复间原样保留。

播种信号靠持久化标记而不是「规则位全零」:用户故意全部取消勾选后位图
同样是零,无标记会把每次开启都误判成首次启用。fuzzy_seeded 是内部键,
设置快照不下发不展示;两份 toml 模板补键,缺键会被升级合并丢弃导致
老用户升级后误重播种。设置页勾选态由 host 处理 configUpdate 后重发的
configSnapshot 驱动,前端无播种逻辑。

新增首次播种用例(播种全开、修剪子集反复开关、重启保持、全取消后不
重播种);默认值三形态补标记断言;既有往返/门控/注入用例改走不门控
视图或规则键直设,避开播种路径保持用例意图纯净。
2026-09-13 09:33:14 +08:00
Jia Sui b807f50d69 feat(server): 模糊音增加总开关并在聚合 getter 门控
[input] 新增 fuzzy_pinyin 总开关,默认关;GetConfiguredFuzzyPinyinOptions
在总开关关时返回全零 options,规则位缓存保留,临时停用后重开立即恢复。
设置页快照改用不门控的规则位视图,总开关关闭时复选框仍展示已存勾选;
configUpdate 分发把精确分支 input.fuzzy_pinyin 排在 fuzzy_ 前缀分支之前。
注入用例改为带总开关的 A-B-A-B,新增门控与默认值断言。
2026-09-13 09:12:45 +08:00
Jia Sui feb7db6de4 feat(server): 模糊音规则接入配置与会话注入
- [input] 段新增 11 个平铺模糊音键(fuzzy_z_zh … fuzzy_uan_uang,默认全关),
  读取用固定键表驱动,键名即 FuzzyPinyinRule 枚举名小写,单一映射可评审;
  位掩码缓存 uint32,reload 先清零再按位合成
- GetConfiguredFuzzyPinyinOptions 合成引擎选项,SetConfiguredFuzzyPinyinRule
  按键写盘(未知名拒绝且不动位图), ApplyConfiguration 每键注入,
  全拼与双拼同路径生效,设置改动即时生效
- settings_app configSnapshot 补 11 字段,ApplyConfigUpdate 用 input.fuzzy_
  前缀分发;windows_webview2 死宿主不接线(InitWebviewSettingsWnd 零调用者)
- 测试:默认值三形态、逐键往返与 0x7ff 合成、未知键拒绝、
  全拼/双拼 zhang→zang 候选注入用例;开发模板同步 11 键
2026-09-13 08:35:25 +08:00
fanlusky 88a086a75b perf(build): Release 编译跳过重复 configure,只构建打包用的目标
本地打包每次都要等很久,其中编译这一段有两处纯粹的重复劳动。

Visual Studio 生成器把 ZERO_CHECK 接进了每个目标,CMakeLists.txt 变化时
构建会自己重新生成,所以已有 CMake 缓存时前置的 cmake --preset 不改变任何
结果,只是每个构建目录白等约 10 秒,三个目录就是 30 秒。改成缓存存在就跳过,
需要强制重跑(改了 preset、换了工具链)时传 -Reconfigure。

构建目标也从 ALL_BUILD 收窄到 MetasequoiaImeServer / MetasequoiaImeTsf。
原先会一并构建 MetasequoiaImeServerTests、test_webview_contract、
test_windows_ipc_contract,而 Prepare-PackageFiles.ps1 随后又把它们从包里
删掉。server 目标的依赖闭包已经覆盖 Watchdog、三个面板、Settings、
DictionaryReplay 和 OpenCC 词典,产物一个不少。要连测试一起构建(比如为了
跑 ctest)传 -Target ALL_BUILD,server/AGENTS.md 里的 ctest 段落已注明。

顺带给 cmake --build 加上 --parallel,真正有代码改动时也能并行。

无改动时的增量编译:TSF 18.0s -> 3.7s,Server 21.9s -> 10.9s。
CI 不受影响,它直接调 cmake,不经过这些脚本。
2026-09-13 03:56:40 +08:00
fanlusky eadcca9f1b fix(server): 候选窗贴边后不再随内容变化上下左右乱飘
候选窗一旦贴住屏幕右边缘,删字后右边缘就会离开边缘;靠近屏幕下边缘时,它有时
正常停在当前行上方,有时却盖住当前行的文字。两者是同一个病根:最终定位用的不是
候选卡片本身,或者压根没有重新定位。

D2D:PlaceAndShow 末尾的水平夹取用的是宿主窗口宽度。宿主带透明阴影内边距、向上
取整到 64px、而且打字过程中只增不减,于是卡片被这些余量整体推离右边缘;删字后过
大的宿主宽度被保留,卡片左边缘就被冻住。改成夹取不透明卡片,和紧随其后的垂直夹取
用同一条规则,透明宿主允许越过屏幕边缘。

WebView2:内容更新只走「重绘不重排」的两条路径,它们从不重做上下翻转判定,只是
(a) 用「MarginTop -= 高度差」这个累加器去粘住底边,(b) 交给
KeepCandidateCardInsideHostAndMonitor 夹取,而后者在卡片底边越过工作区下边界时
直接把卡片往上推。于是本来在光标下方勉强放得下的候选窗,一旦下一个键多出一行释义
变高,就被径直推到当前行文字上面。现在两条路径都改为用实际绘制出来的尺寸重新推导
锚点(ReanchorCandidateHostToPaintedCard)——翻转判定只能信这个高度——两个累加器
随之删除。

另有两处一致性修补:MarginLeft/MarginTop 超出宿主可用余量时改为平移宿主而不是截断,
截断会让卡片毫无理由地吸到宿主的另一端;翻转判定的下边界改用 coordinates.bottom -
edgePadPx,与下游所有夹取共用一条线,否则会判定「下方放得下」,再被 2 DIP 边距顶
回来压到文字上。

已知未处理:翻转时的行高仍是写死的 24 DIP,因为 IPC 只传了文本范围的 left 和
bottom,行高在 GetPhysicalTextAnchor 里就被丢掉了。宿主报出的行比 24 DIP 高时,
翻转后的卡片底边仍会压进那一行——但现在是稳定的偏低,不再忽上忽下。
2026-09-13 03:07:52 +08:00
fanlusky 426bb9d544 feat(server): WebView2 候选窗口支持滚轮翻页,并加上开关
PR #351 只给 D2D 候选窗口加了滚轮翻页,WebView2 后端还没有。

网页端只上报一个以 WHEEL_DELTA 为单位的原始 delta,notch 累加仍然
交给 CandidateWheel::ConsumeWheelDelta,两个宿主最终都汇聚到
WM_PAGE_CANDIDATE,翻页逻辑一份不重复。Chromium 的 wheelDeltaY 本身
就是 ±120/格,正是原生累加器期望的单位,deltaY 作标准回退。

同时把滚轮翻页做成可配置的 general.paging_mouse_wheel,默认关闭:
候选宿主是 NOACTIVATE 且从不获得焦点,滚轮只有在 Windows「当我悬停
在非活动窗口上方时滚动它们」开启时才会送达,无法无条件承诺,所以设为
opt-in,设置页里也写明了这个前提。开关只在 WM_PAGE_CANDIDATE 一处
判断;D2D 那边在 WM_MOUSEWHEEL 顶部提前返回 false,关闭时消息照旧
交给 DefWindowProc,与功能加入前一致。
2026-09-13 01:02:02 +08:00
Lu Fan(SonnyCalcr) 87158b2262 Merge pull request #351 from LilyCarry/feat/candwnd-mousewheel-paging
feat(server): support mouse wheel paging on candidate window (refs #81)
2026-09-13 00:38:00 +08:00
fanluskyandCarry 63eab2f88f refactor(server): 滚轮翻页复用键盘翻页逻辑并移出按键热路径
在 #351 的基础上收敛几处实现:

- CloseContextMenu 里新增的 hover 清理和 Present 原本无条件执行,而
  ShowFromGlobalState 和 Hide 每次按键都会调它。没有菜单时按键路径上会多出
  一次整屏重绘,画的还是更新前的旧场景。改为只在确实有 popup 要销毁时才做。
- 分页与动态扩充的逻辑从键盘路径复制了一份。抽成
  ExpandCandidatesKeepingPagePosition 和 MoveCandidatePage,键盘与滚轮共用。
- 每一格滚轮直接投一个任务,而每个任务都可能回引擎和用户词库。改为在队列里
  合并成 page_steps,一次快速滚动只重绘一次。
- 窗口层不再直接调 EnqueueCandidateUiAction,改为与其它候选窗动作一致,
  PostMessage 到宿主 WndProc(WM_PAGE_CANDIDATE)。EnqueueCandidateUiAction
  的序号校验随之恢复原样。
- 滚轮累加拆成纯函数 CandidateWheel::ConsumeWheelDelta 并补上单元测试;
  候选窗隐藏时忽略迟到的滚轮消息。

滚轮翻页仍然把页内高亮重置为首项,键盘翻页仍然保留原有的页内索引。

Co-authored-by: Carry <xu762556@outlook.com>
2026-09-13 00:16:18 +08:00
fanlusky e860eaab43 fix(server): 避免 WebView2 候选窗阴影被裁切 2026-09-12 23:50:31 +08:00
fanlusky c01327cc0b feat(server): 设置窗口关闭后先隐藏,十分钟后才真正退出
设置是独立进程,每次关闭都销毁窗口,于是每次打开都要重新建 WebView2 并等首屏
导航,冷启动的等待完整地落在用户那一侧。现在用户关闭窗口只隐藏,进程继续持有
已经导航完成的 WebView2,十分钟内重新打开直接显示,超时后才走原来的退出路径。

隐藏期间停掉 300ms 的配置轮询并把 controller 置为不可见,让页面被浏览器降频,
免得一个看不见的窗口白占 CPU。重新露面时在 CancelLinger 里对称地恢复,并调用
WM_ACTIVATE 早就接好、此前一直是空操作的 Resume()。

窗口隐藏时保留的那一帧会在下次打开时先显示出来,所以隐藏前要清掉标题栏按钮的
悬停高亮,复用 WM_SIZE 在最小化/还原时已经用的那个 helper。页面自己的关闭按钮
在点击时清,这里覆盖 Alt+F4、启动图关闭按钮等不经过页面的路径。顺带清掉本地的
g_maximize_button_hover:隐藏的窗口收不到 WM_NCMOUSELEAVE,那个标志会一直是脏
的,下次真的移进去时不再发 enter。

两个边界:

首帧之前被关掉直接真退出,不留半初始化的进程——那种进程重新打开时启动图已经
dismiss 过,用户会对着一个空窗口等。这条同时覆盖启动图自己的关闭按钮,它发的
也是 WM_CLOSE。

CloseSettingsApplication() 是 Ctrl+Shift+Alt+T 的终止通道,紧接着 Server 自己
也要退出。它原来发 WM_CLOSE,而 WM_CLOSE 现在只隐藏,会把设置进程留在 Server
之后,所以改发新的 kQuitSettings 直通真正的退出。

OpenSettingsApplication() 不再自己 ShowWindow,改为投 kActivateSettings 让设置
进程自己处理:在这边直接显示不会取消延迟退出定时器,窗口会在用户面前被销毁,
而且 SW_SHOWNORMAL 还会把之前最大化的窗口还原掉。
2026-09-12 22:48:31 +08:00
Carry 0fb8001022 feat(server): support mouse wheel paging on candidate window (fixes #81) 2026-09-12 19:46:24 +08:00
fanlusky 2717000084 fix(server): 候选窗右侧截断——去重的 show 不再让待应用的裁剪区失效
WebView2 后端下候选窗右边总是被竖切一刀,且多半切在最后一个候选上。

根因在 WndProcCandWindow:g_candidate_content_generation 在处理函数最顶
部无条件自增,早于下面的去重判断。IME 每次按键会投递两次 show,于是每一
次按键的时序都是:

  gen N   -> path=content-only,InflateCandWnd 带着捕获 gen N 的回调发出
             DOM 更新;
  gen N+1 -> 帧签名相同,走 path=dedup,什么都没画、什么都没排,但把计数
             器留在了 N+1;
  gen N 的 dom-callback 触发,发现 N != N+1,直接 return。

结果 SetWindowRgn 只会被完整的 FineTune 刷新,区域一直冻结在上一次
FineTune 量到的宽度上。后面的候选页一旦更宽,就会画到过期区域之外 —— 右
边缘的硬切;更窄时只是多出一块看不见的透明留白,所以这个 bug 时隐时现。

诊断日志(851 行)里的证据:path=content-only 18 次,dom-callback 世代
不匹配 18/18,clip-measure-submit / apply / drop 全为 0,ui-region 7 次且
全部来自 path=full-layout。区域算术本身在 7 个样本里都正确。

修复:去重分支把世代还回去(compare_exchange,仅在期间无人自增时生效),
因为一次去重的 show 什么都没画,DOM 里仍然正是那次在途测量对应的帧。上面
的 path=superseded 出于同样的原因在自增之前就返回。

顺带修掉同一个 bug 的另一半与它为何一直查不出来:

- 去掉 #msime-fast-layout 那张给 .container/.row-wrapper 强加
  white-space:nowrap!important 的样式表(并在应用外观时移除历史残留),
  它让卡片长过 --msime-max-width-dip 的上限,再被 overflow-x:hidden 从字
  中间切开;
- ApplyCandidateFrame 与测量脚本改用 max-content,不再写死 nowrap;
- 量测宽度纳入 scrollWidth,并把 rw/ow/sw/cw 一并回传;
- webview_utils.cpp 里被 #define 成空操作的 ui-measure 日志恢复,裁剪路径
  的静默 return 补上 clip-refresh-skip 一行 —— 正是这两处的沉默让「区域
  从不刷新」在日志里毫无痕迹。

新增的日志只记录数字、坐标与状态,不含任何用户输入或候选文本。

Release / Debug 构建通过,ctest -C Debug 2/2 通过,打包安装后人工验证不再截断。
2026-09-12 18:42:04 +08:00
fanlusky a8c10a63a6 feat(settings): add a restart-server button under the UI backend setting
界面渲染 only takes effect at Server startup, so the setting already told the
user to restart the IME process but offered no way to do it outside the
Ctrl+Shift+Alt+R developer shortcut. Add a 重启 button in the same section.

Settings runs in its own process, so the request travels the Aux pipe — the
supported cross-integrity control path the config notifications already use —
and the Server exits with the Watchdog restart code. The shortcut and the
button now share RestartServerProcess().
2026-09-12 16:41:13 +08:00
fanlusky aba4851f90 test: do not require a lattice sentence from the locked dictionaries
Whether a whole-sentence candidate is produced depends on which
dictionaries and local models are installed, so the CI product data
yields none and the presence requirement failed there.  Check the
canonical reading of whatever whole-sentence candidates do appear, and
assert the shuangpin-to-quanpin conversion unconditionally instead --
that conversion is the value the fix attaches to those candidates.
2026-09-12 16:01:42 +08:00