diff --git a/.agents/skills/iterate-macos-ime/SKILL.md b/.agents/skills/iterate-macos-ime/SKILL.md index d08d64b3..5bf9c278 100644 --- a/.agents/skills/iterate-macos-ime/SKILL.md +++ b/.agents/skills/iterate-macos-ime/SKILL.md @@ -109,6 +109,8 @@ make macos-clean-install APP=…/Lampyris.app ARGS=--input-source-removed 会让人(和用户)误判成「换了没生效」;若该进程的 bundle 已被删除,它的安装会 在替换宿主后中断(无 receipt、staging 残留)——报告214 §8。 1. 输入源还原 ABC;`config.toml` SHA、宿主 PID、执行 SHA 回读。 + **换包后必查「跑的是新二进制」**:`lsof -p "$(pgrep -x Lampyris)" | grep 'MacOS/Lampyris' | grep -c deleted` + 必须为 0;否则进程还持有被替换掉的旧文件,HUD/候选的旧行为会被误当成新包的缺陷(报告220)。 2. 自建文档/窗口关闭(不碰用户真实文档)。 3. 证据放 `platforms/macos/build//`(该目录不入库),报告写证据文件名。 4. `make verify` 全绿 → 固定`dev`提交/推送;同批`dev → main` PR持续跟进至合并或关闭,不按验收轮次另开分支/PR;HANDOFF §1/§3/§4/§9同步(ADR-246/248)。 diff --git a/ROADMAP.md b/ROADMAP.md index 1920622b..64c2d04b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -245,7 +245,8 @@ ADR-147:Terminal正常实际提交“你”及wq前缀确认后的failure/INT/ - [ ] **2026-10-27 MVP 正式发布(目标 `1.0.0`,ADR-298)**:阶段 A 在 0.1.16/26 上演练 IT-22(`docs/tasks/T-ITER-22.md`)→ 修复入库 → 阶段 B 递增 1.0.0(build 27)并双机装同一包(同时闭环「更新器自更新」)→ 阶段 C 只在 1.0.0 上做正式 IT-22、IT-15 独立重建、IT-23 五工作日 → 阶段 D `tag v1.0.0` 打在验收候选提交 → Release → Pages。 - [x] 2026-10-28 **锁定候选 1.0.0 / build 29**(报告217):提交 `11abece3`、ZIP `aca4ed5c…`、宿主执行 `4ad949e6…`,air 已装同包。装机定向复验:2.1 多词拼接 PASS、9.1 编码播报 PASS、11.1 候选跟随(6 张截图:下方/镜像/底部翻上,无压字)PASS。**IT-22 限定范围通过**(必查 11 + 抽查 8 全 PASS,其余沿用报告216 基线)。 - [x] 2026-10-28 **IT-15 独立离线重建通过(报告218)**:用户在 air 上从候选自带源码包离线重建 1.0.0/build 29,身份链与逐架构网络导入 PASS、**默认数据 4/4 逐字节一致**、空闲 0 外联;可执行文件因本机 SDK 27.0 vs 发布 26.5 不同(ADR-033 边界)。顺带修掉 `run-repro.sh` 的缺陷:原来 `cmp` 可执行文件失败会中断,导致数据表比对根本没跑,且漏比 `variant-s2t.tsv`。 -- [ ] 2026-10-29 IT-23 五工作日试用(**1.0.0 / build 30**,air 与 pro 执行 SHA 均为 `16ac739f…`):两台设备已装同一包(报告217 §8)。build 29 因「候选窗跟随鼠标」缺陷(报告219)被 30 取代,IT-23 尚未起算,故换包不影响基线。 +- [ ] 2026-10-29 IT-23 五工作日试用(**1.0.0 / build 31**,air 与 pro 执行 SHA 均为 `9cbe8a22…`):两台设备已装同一包(报告217 §8)。29→30 修「候选窗跟随鼠标」,30→31 只加 HUD 诊断(报告220,实为旧进程未退出);IT-23 尚未起算。 +- [x] 2026-10-29 **「HUD 不显示」结案(报告220)**:三次报告分别是诊断开关未生效无法取证、候选窗跟随鼠标(真缺陷,build 30 修)、**换包后旧进程仍在运行**。build 31 把诊断改为文件开关并给 `showStateFeedback`/`hudCaretRect` 各分支打点;`iterate-macos-ime` 收尾清单新增「`lsof … | grep -c deleted` 必须为 0」。 - [x] 2026-10-29 **候选窗跟着鼠标而不是插入点(报告219,build 30)**:`caretRect` 给解析器传了 `allowsEmptyCaret: false`,`length()==0` 时解析直接返回 nil → 落到鼠标兜底;改为 `true`,并把回退链收敛为「本轮 → 上次成功位置 → 鼠标」,新增 3 条断言。两台设备已装 build 30。 - [~] I3/IT-12/23:IT-12 性能/内存/久用已 PASS(报告191/192/199/207);**IT-23 五工作日试用尚未开始**(模板报告211)。 - [~] I4/IT-13…15:IT-13 附件绑定已在两次 Release 对账(报告194/208);**IT-14 最终候选锁定、IT-15 第三方核验待 0.1.15**。 diff --git a/crates/ime-core/src/lib.rs b/crates/ime-core/src/lib.rs index 9ffa1b34..2dd8af19 100644 --- a/crates/ime-core/src/lib.rs +++ b/crates/ime-core/src/lib.rs @@ -206,9 +206,10 @@ impl Session { self.english = !self.english; self.cancel(); } + /// 全角/半角只影响**之后**提交的标点与数字,与当前编码无关(ADR-302): + /// 组合中的缓冲区与候选必须保留,否则用户按一次 ⌥F 就会丢掉正在打的字。 pub fn toggle_width(&mut self) { self.width_override = Some(!self.width_override.unwrap_or(self.full_width)); - self.cancel(); } /// 当前是否全角(含临时切换覆盖)。 @@ -283,9 +284,20 @@ impl Session { self.mode } + /// 切换方案时保留已输入的编码,并按新模式重算候选(ADR-302)。 + /// + /// 新模式若没有候选,缓冲区仍然保留(用户可以继续补码或按 Esc 取消), + /// 不再像旧行为那样把整段编码清掉。这里用 `recompute_with_commit(false)`: + /// 换模式不应触发「四码自动上屏」,那会让用户莫名其妙多出几个字。 pub fn set_mode(&mut self, mode: InputMode) { + if self.mode == mode { + return; + } self.mode = mode; - self.cancel(); + if self.composing { + self.page = 0; + let _ = self.recompute_with_commit(false); + } } pub fn cycle_mode(&mut self) -> InputMode { @@ -1168,6 +1180,60 @@ mod tests { assert_eq!(InputMode::PinyinOnly.next(), InputMode::Mixed); } + #[test] + fn toggle_width_keeps_the_composition() { + let mut s = session_with_wubi(InputMode::WubiOnly); + assert_eq!(s.handle_key('w'), KeyOutcome::Consumed); + assert_eq!(s.handle_key('a'), KeyOutcome::Consumed); + let before = s.candidates().to_vec(); + assert!(!before.is_empty()); + s.toggle_width(); + assert!(s.is_full_width(), "width toggles"); + assert!( + s.composing(), + "width switch must keep the composition (ADR-302)" + ); + assert_eq!(s.buffer(), "wa", "buffer survives the width switch"); + assert_eq!( + s.candidates(), + before.as_slice(), + "candidates survive unchanged" + ); + } + + #[test] + fn set_mode_keeps_the_buffer_and_recomputes() { + let mut s = session_with_wubi(InputMode::WubiOnly); + assert_eq!(s.handle_key('a'), KeyOutcome::Consumed); + assert_eq!(s.buffer(), "a"); + assert!(!s.candidates().is_empty()); + s.set_mode(InputMode::Mixed); + assert_eq!(s.mode(), InputMode::Mixed); + assert!( + s.composing(), + "switching mode must keep the composition (ADR-302)" + ); + assert_eq!(s.buffer(), "a", "buffer survives the mode switch"); + assert!( + !s.candidates().is_empty(), + "candidates are recomputed for the new mode" + ); + } + + #[test] + fn set_mode_without_candidates_keeps_the_buffer_for_escape() { + // 新模式没有任何候选时也不能清空编码:用户还可以 Esc 或继续补码。 + let mut s = Session::new(InputMode::Mixed); + s.set_wubi_engine(Box::new(MockEngine)); + assert_eq!(s.handle_key('c'), KeyOutcome::Consumed); + assert!(s.candidates().is_empty()); + s.set_mode(InputMode::PinyinOnly); + assert_eq!(s.buffer(), "c"); + assert!(s.candidates().is_empty()); + assert_eq!(s.handle_key('\u{1b}'), KeyOutcome::Consumed); + assert!(!s.composing(), "escape still cancels"); + } + #[test] fn mode_from_str() { assert_eq!("mixed".parse::(), Ok(InputMode::Mixed)); diff --git a/crates/ime-ffi/src/session.rs b/crates/ime-ffi/src/session.rs index b6d7cf73..ac6d3444 100644 --- a/crates/ime-ffi/src/session.rs +++ b/crates/ime-ffi/src/session.rs @@ -1535,7 +1535,9 @@ mod tests { // SAFETY:候选指针借自会话,测试期间保持有效。 assert_eq!(unsafe { (*wubi.items).source }, SOURCE_CODE_TABLE); - // 拼音候选。 + // 拼音候选。ADR-302 起切换方案会**保留**已输入的编码,所以先显式取消, + // 否则五笔的 "wq" 会被带进拼音缓冲(这正是新语义要的效果,本用例只是要干净的起点)。 + assert_eq!(ime_session_key(s, 0x1b), IME_KEY_CONSUMED); let pinyin_mode = CString::new("pinyin").unwrap(); assert_eq!(ime_session_set_mode(s, pinyin_mode.as_ptr()), IME_OK); for ch in "nihao".chars() { diff --git a/docs/reviews/217-1.0.0-freeze.md b/docs/reviews/217-1.0.0-freeze.md index c663e348..7822d523 100644 --- a/docs/reviews/217-1.0.0-freeze.md +++ b/docs/reviews/217-1.0.0-freeze.md @@ -123,15 +123,15 @@ platforms/macos/tests/run-repro.sh \ - **静态零外联**:`./scripts/verify-no-network.sh`(或 `make verify-no-network`)通过。 - **运行时零外联**:一边打字一边 `lsof -i -a -p "$(pgrep -x Lampyris)"`,预期**无输出**。 -## 8. IT-23 试用基线:两台设备装同一个包(2026-10-29 更新为 build 30) +## 8. IT-23 试用基线:两台设备装同一个包(2026-10-29 更新为 build 31) | 项 | air(本机,arm64 / macOS 27.0.1) | pro(x86_64 / macOS 13.7.8) | |---|---|---| -| 版本 / 构建 | **1.0.0 / 30** | **1.0.0 / 30** | -| 宿主执行 SHA-256 | `16ac739f135ef1e9abfd750956a54411da3313e9cf675bdf7f50a165d246bddb` | **同值**(同一个二进制) | -| 更新程序构建号 | 30 | 30 | +| 版本 / 构建 | **1.0.0 / 31** | **1.0.0 / 31** | +| 宿主执行 SHA-256 | `9cbe8a22e7e4d5af5e67e4e60bf00e5e53bd3e7032d02dfab8264b997c1c630b` | **同值**(同一个二进制) | +| 更新程序构建号 | 31 | 31 | | 签名 / 公证 | Developer ID + `spctl` Notarized | 同(`codesign --strict`、`stapler validate` 均 PASS) | -| 用户数据 | config `cfc86cff…`、词库 800+ 行 | config `cb8e7d05…`、词库 **685 行**(build 30 装前装后一致) | +| 用户数据 | config `cfc86cff…`、词库 800+ 行 | config `cb8e7d05…`、词库 **690 行**(build 31 装前装后一致) | | 旧版本备份 | `updates/`、`uninstalled/` 历史项 | `uninstalled/bundle.aPZKev`(0.1.16) | | 输入源 | `cn.jokerhub.inputmethod.Lampyris.default` 已启用 | 已启用;当前停在 ABC,使用时按 ⌃Space 切 | @@ -143,3 +143,5 @@ pro 的安装记录:`ev-95-pro-stage.log`(ZIP 校验 → 解包 → 停宿 > **为什么从 29 换到 30**:build 29 装机复验时发现「聚焦输入框 + 鼠标在远处 → 候选窗跟着鼠标」, > 根因与修法见报告 219。IT-23 尚未开始记第 1 天,因此换包不影响试用基线;两台设备均已升级到 30。 +> **再从 30 换到 31 的原因**:31 只增加 HUD 诊断(文件开关 + 分支打点),用于证实第三次「HUD 不显示」 +> 实为**旧进程仍在运行**(报告 220),不是产品缺陷。诊断文件已删除,无文件时零开销;两台设备均已在 31。 diff --git a/docs/reviews/220-hud-stale-process.md b/docs/reviews/220-hud-stale-process.md new file mode 100644 index 00000000..690d34b4 --- /dev/null +++ b/docs/reviews/220-hud-stale-process.md @@ -0,0 +1,41 @@ +# 220 「HUD 不显示」的结论:不是缺陷,是换包后仍在跑旧二进制 + +> 用户第三次报告「状态变化 HUD 没出现」。前两次都没能定位,因为诊断开关没生效(日志文件根本没写)。 +> 本次把开关改成文件方式并给三个分支打点后,用户复测:**HUD 与候选窗都正常**, +> 之前的现象是**换包后新版本没有实时生效**(运行中的宿主还是旧进程/旧二进制)。 + +## 1. 为什么诊断开关之前"看起来没开" + +`LAMPYRIS_HUD_DIAG` 由 `launchctl setenv` 设置,但宿主是 **IMK 通过 launchd 拉起**的: +`ps -Eww` 在这台机器上不显示环境(`-E` 不再输出),而 `lsof`/`ps` 又无法证明变量传进去了, +于是「日志文件不存在」既可能是没开日志、也可能是没走到该分支——**不可区分**。 + +## 2. 改法(已随 build 31 发布) + +| 改动 | 说明 | +|---|---| +| 文件开关 | `~/Library/Application Support/Lampyris/hud-diag` 存在即开启;不再依赖 `launchctl setenv` | +| 分支打点 | `showStateFeedback` 记录走「内联芯片」还是「HUD」,以及面板可见性/组合态/传给 HUD 的光标矩形 | +| 入口打点 | `hudCaretRect` 记录 IMK 客户端 bundle、前台应用 bundle、`length()/selection()` | +| 失效守卫 | 每条 `return nil` 都写原因(沿用报告 219 的补法) | + +诊断用完即删该文件(本次已在两台机器上移除);代码保留,无文件时零开销。 + +## 3. 换包后必须确认「跑的是新二进制」 + +这次真正的教训在**运维动作**上:包换好了、文件 SHA 也对,但**正在运行的宿主进程可能仍是旧映像**。 +判别方法(已写进 `iterate-macos-ime` 收尾清单): + +```sh +P=$(pgrep -x Lampyris | head -1) +lsof -p "$P" | grep 'MacOS/Lampyris' | grep -c deleted # 必须为 0 +shasum -a 256 "$HOME/Library/Input Methods/Lampyris.app/Contents/MacOS/Lampyris" +``` + +- `deleted` 计数 > 0 → 进程持有的是**已被替换掉的旧文件**,HUD/候选的旧行为都可能是它造成的。 +- 文件 SHA 与运行进程的 `lsof` 路径一致、且 `deleted` 为 0,才算"新版本已生效"。 + +## 4. 结论 + +- **HUD 无缺陷**:报告 219/220 三次「不显示」分别是(a)诊断开关未生效无法取证、(b)候选窗跟随鼠标(真实缺陷,已在 build 30 修)、(c)**旧进程未退出**。 +- 试用基线确定为 **1.0.0 / build 31**:air 与 pro 执行 SHA 均为 `9cbe8a22…`,`running_deleted=0`。 diff --git a/docs/reviews/221-switch-keeps-composition.md b/docs/reviews/221-switch-keeps-composition.md new file mode 100644 index 00000000..ae3ab014 --- /dev/null +++ b/docs/reviews/221-switch-keeps-composition.md @@ -0,0 +1,39 @@ +# 221 组合态下的状态切换不再丢字(ADR-302) + +> 用户报告:编码已出现候选框时,按 **Shift / ⌥M / ⌥F** 会立刻让候选框和编码一起消失; +> 而 ⌥T(简繁)/⌥P(标点)是在候选窗里内联反馈的。要求分析合理性并改善连续输入体验。 + +## 1. 代码事实 + +| 快捷键 | 调用链 | 旧行为 | +|---|---|---| +| Shift(中英) | `ime_session_toggle_english` → `Session::toggle_english()` → `self.cancel()` | 缓冲区清空 | +| ⌥M(模式) | `ime_session_set_mode` → `Session::set_mode()` → `self.cancel()` | 缓冲区清空 | +| ⌥F(全角) | `ime_session_toggle_width` → `Session::toggle_width()` → `self.cancel()` | 缓冲区清空 | +| ⌥T / ⌥P | 只写配置 + `prepare()` | 候选窗保留、芯片内联更新 | + +## 2. 判断 + +- **⌥F 全角取消组合不合理**:全角只影响之后提交的标点/数字,与当前编码无关,取消没有收益。 +- **⌥M 模式取消组合不合理但可理解**:旧编码的候选确实会变,但"换个思路继续打"是真实场景,保留编码并按新模式重算更贴近意图;无候选时用户按 Esc 即可。 +- **Shift 切英文丢字最刺眼**:按 Shift 的动机通常是"这段想打英文",用户期望字母留下;直接删除与动机相反。 + +## 3. 改法(ADR-302) + +| 快捷键 | 新行为 | +|---|---| +| Shift → 英文(组合中) | 先把编码**原样上屏**(`nihao` → `nihao`),再切英文;宿主侧用已有 `insertText`,无 ABI 变更 | +| ⌥M 模式 | 保留缓冲区,按新模式 `recompute_with_commit(false)` 重算候选,**不自动上屏**;无候选也保留编码 | +| ⌥F 全角 | 保留缓冲区,只影响之后的提交 | +| ⌥T / ⌥P | 不变 | + +## 4. 验证 + +- core 新增 3 个单测(全角保留组合与候选、切模式保留缓冲区并重算、新模式无候选时保留编码且 Esc 仍可取消)。 +- `ime-ffi` 的 `candidates_expose_source_codes` 原先依赖"切模式会清空缓冲",改为先 Esc 再切模式。 +- 离线回归 56 文件 / 303 例、`make verify` 全绿;实机复验见 §5。 +- 实机复验(build 32,两台设备):待填。 + +## 5. 遗留 + +- 组合中切模式若新模式无候选,候选窗会隐藏、编码以预编辑文本留在输入框里(用户按 Esc 取消或继续补码);这是刻意的取舍,写进发布说明的已知行为。 diff --git "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" index 2087cade..c8bdab9e 100644 --- "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" +++ "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" @@ -2734,6 +2734,19 @@ - **验证**:新增 `platforms/macos/tests/caret-placement.swift` + `test-caret-placement.py`(8 组不变量:中部向下、底部翻上、顶部仍在下方、字形框归一化、右边缘镜像、HUD 居中向上、夹取、面板超尺寸、副屏);`candidate-presentation`、`appearance-contract`、`preview-menu` 等复用同一源文件复跑;`make verify` 与 `make macos-offline-tests`(56 文件/303 例)全绿。 - **关联**:ADR-214(HUD 光标解析两趟)、ADR-211(状态芯片/ HUD)、报告 095/096/216 §11.1、`platforms/macos/Sources/{CaretPlacement,CandidateWindow,StateHUD,InputController}.swift`。 +## ADR-302:组合态下的模式/全角/中英切换不再丢弃编码 + +- **编号**:302 +- **决策**: + 1. **Shift 切英文**:若正在组合且切换到英文,先把当前编码**原样上屏**(`nihao` → 文本 `nihao`),再切英文。实现放在宿主(`InputController.toggleEnglish`),调用已有的 `insertText`,不改 FFI/ABI;核心的 `toggle_english` 行为不变(它随后清空的是已经提交过的空缓冲)。 + 2. **⌥M 切模式**:`Session::set_mode` 不再 `cancel()`,改为保留缓冲区并按新模式 `recompute_with_commit(false)` 重算候选(**不触发四码自动上屏**);新模式没有候选时缓冲区仍保留,用户可以继续补码或按 Esc 取消。 + 3. **⌥F 全角/半角**:`Session::toggle_width` 不再 `cancel()`——全角只影响随后提交的标点与数字,与当前编码无关。 + 4. ⌥T(简繁)与 ⌥P(标点)维持原行为(只改配置、候选窗内联反馈)。 +- **理由**:用户报告「输入编码 + 候选窗出现后,按 Shift/⌥M/⌥F 会立刻把编码和候选窗一起清掉」。这三处在核心层主动 `cancel()`,而另外两个状态开关不取消,导致同样的"状态切换"体验不一致。丢弃用户刚打的字在任何输入法里都是负收益:切英文时用户要的是字母(原样上屏最贴近意图),切模式/切全角时用户要的是"继续打"(保留编码最贴近意图)。 +- **边界**:不新增配置项;不改候选排序、键位与 UI;英文模式的 `toggle_english` 语义不变(无组合可提交);组字中 `set_mode` 不自动上屏,避免用户"莫名多出几个字"。 +- **验证**:`crates/ime-core` 新增 3 个单测(`toggle_width_keeps_the_composition`、`set_mode_keeps_the_buffer_and_recomputes`、`set_mode_without_candidates_keeps_the_buffer_for_escape`);`crates/ime-ffi` 的 `candidates_expose_source_codes` 改为先显式 Esc 再切模式(旧用例依赖了已删除的取消语义);`test-controller-menu`、`test-composition-navigation`、`make verify`、`make macos-offline-tests`(56 文件/303 例)全绿;实机复验见报告 221。 +- **关联**:ADR-062(模式/限长)、ADR-211(状态反馈)、`crates/ime-core/src/lib.rs`、`platforms/macos/Sources/InputController.swift`、报告 221。 + ## 待定项 | 编号 | 议题 | 说明 | diff --git a/packaging/build-number b/packaging/build-number index e85087af..f5c89552 100644 --- a/packaging/build-number +++ b/packaging/build-number @@ -1 +1 @@ -31 +32 diff --git a/platforms/macos/Sources/InputController.swift b/platforms/macos/Sources/InputController.swift index ff7966c0..8725c894 100644 --- a/platforms/macos/Sources/InputController.swift +++ b/platforms/macos/Sources/InputController.swift @@ -246,6 +246,19 @@ final class LampyrisInputController: IMKInputController { } @objc private func toggleEnglish(_ sender: Any?) { + // ADR-302:切到英文时,把已经输入的编码**原样上屏**,不要丢字。 + // 用户按 Shift 的动机通常是「这段想打英文」,保留字母比清空更符合预期。 + if let session, session.isComposing, !session.isEnglish, + let client = activeClient ?? (sender as? IMKTextInput) + { + let raw = session.buffer + if !raw.isEmpty { + clearMarked(client) + client.insertText(raw, replacementRange: NSRange(location: NSNotFound, length: 0)) + if Self.hudDiagEnabled { Self.hudDiag("toggle-english committed raw=\(raw)") } + } + session.reset() + } session?.toggleEnglish() showStateFeedback(StateFeedback.language(session?.isEnglish ?? false)) refreshAfterStateChange()