Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/iterate-macos-ime/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<acceptance>/`(该目录不入库),报告写证据文件名。
4. `make verify` 全绿 → 固定`dev`提交/推送;同批`dev → main` PR持续跟进至合并或关闭,不按验收轮次另开分支/PR;HANDOFF §1/§3/§4/§9同步(ADR-246/248)。
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**。
Expand Down
70 changes: 68 additions & 2 deletions crates/ime-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/// 当前是否全角(含临时切换覆盖)。
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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::<InputMode>(), Ok(InputMode::Mixed));
Expand Down
4 changes: 3 additions & 1 deletion crates/ime-ffi/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
12 changes: 7 additions & 5 deletions docs/reviews/217-1.0.0-freeze.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 切 |

Expand All @@ -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。
41 changes: 41 additions & 0 deletions docs/reviews/220-hud-stale-process.md
Original file line number Diff line number Diff line change
@@ -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`。
39 changes: 39 additions & 0 deletions docs/reviews/221-switch-keeps-composition.md
Original file line number Diff line number Diff line change
@@ -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 取消或继续补码);这是刻意的取舍,写进发布说明的已知行为。
13 changes: 13 additions & 0 deletions docs/决策记录.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

## 待定项

| 编号 | 议题 | 说明 |
Expand Down
2 changes: 1 addition & 1 deletion packaging/build-number
Original file line number Diff line number Diff line change
@@ -1 +1 @@
31
32
13 changes: 13 additions & 0 deletions platforms/macos/Sources/InputController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading