fix(vocab-card): 收起添加词卡片后,胶囊要回到屏幕中间 (#969)

* fix(vocab-card): 收起卡片时把胶囊的位置也还回去

卡片和录音胶囊是同一个窗口。弹卡片要把窗口挪到右下角,收卡片时
`hide_vocab_suggestion_card` 还了尺寸、还了鼠标穿透,唯独没还位置 ——
于是下一次录音,胶囊出现在右下角。

漏这一步之所以致命,是因为 `maybe_position_capsule_bottom_center` 有个
去重缓存,而那个缓存只记「显示器 + 翻译态」。卡片这一挪它一无所知,
下次录音时拿到相同的显示器快照就判定「没变化」,直接跳过重新定位。
换句话说:窗口被挪走了,而唯一会把它挪回来的那段代码以为自己不用动。

所以复位和清缓存两件事都做,各堵一个方向:清缓存保证「就算这次复位
失败,下一次 emit_capsule 也一定会重算」;复位保证「就算有哪条路径
绕过 emit_capsule 直接 show,窗口也已经在对的地方」。

顺带把同一形状的另一处堵上:卡片直接调 set_ignore_cursor_events,却没
同步 `capsule_cursor_passthrough` 那个缓存。目前触发不了,但一旦缓存和
窗口真实状态分家,emit_capsule 就会跳过它该调的那一次 —— 表现是经典
胶囊上的 ✓/✕ 点不动。

复位顺序改成先 hide 再改几何:尺寸和位置要一起动,窗口还亮着时改就有
概率被合成出一帧「卡片被拉宽、还横着飞过半个屏幕」。

真机量过(临时脚手架 + CGWindowList 取窗口 frame,外接屏 1920x1080):

  修复前  录音 → x=453(居中)→ 卡片 x=1299(右下)→ 收卡片后窗口
          仍在 x=1299 → 下一次录音胶囊 x=1299  ← bug
  修复后  录音 → x=526(居中)→ 卡片 x=1168(右下)→ 收卡片当场回到
          x=453(居中)→ 下一次录音胶囊 x=453  ← 对

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit d3fedcd13da0a3ba002a6f29dda330eeabee62f1)

* test(capsule): 把「卡片借走胶囊窗口必须完整归还」钉成契约

上一条修复(收起卡片时把胶囊位置也还回去)在 8 月丢过一次 —— 它只活在
一个没合并的本地分支上,主线从 PR #917 重新长出了漏还位置的版本,于是
真机 bug 原样复发:用过一次带添加词的卡片,下一次录音的胶囊就出现在
右下角,再也回不到底部居中。

这类不变量单测抓不到:整段是 Tauri 窗口调用,跑在 run_on_main_thread 的
闭包里,没有窗口就没有断言对象。所以按 repo 既有的做法收进
macos-capsule-spaces-contract —— 那个文件已经是「胶囊窗口几何」这类
源码级不变量的归属地(多屏定位缓存那条就在里面)。

守两侧四个函数(词条卡片 + 落字回退卡片):

  弹卡片:挪走了共享窗口,就必须让 capsule_layout 去重缓存当场作废,
          并同步 capsule_cursor_passthrough
  收卡片:穿透、尺寸、位置、两个缓存,一样都不能少;且必须先 hide
          再改几何(否则复原会被合成出一帧横飞过半个屏幕的动画)

「还位置」和「清缓存」分开断言是有意的:这两件事各堵一个方向,漏任何
一个 bug 都会回来,所以要能各自报出自己的名字。

验证(三态):

  未修复的 coordinator.rs(HEAD~1)  → 红,报 show_vocab_suggestion_card
                                       缺 capsule_layout 失效
  变异:只删掉 hide_vocab 里那一行
       position_capsule_bottom_center → 红,且精确报出「restoring size
                                       alone leaves it in the card's
                                       bottom-right corner」
  修复后                              → 绿;npm test 全量退出码 0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bigsongandClaude Opus 5 authored and GitHub committed 2026-08-19 23:30:48 +08:00
1 parent dbda116415
commit ec5ea4b07a
2 files changed
+115 -2

No files matched your search

@@ -75,3 +75,87 @@ assertMatch(
/#\[cfg\(target_os = "macos"\)\][\s\S]*?crate::capsule_target_monitor\(window\)/,
'macOS capsule layout cache key must reuse capsule_target_monitor, or it will skip repositioning when the cursor moves to another screen',
);
// === 卡片借走胶囊窗口后必须完整归还(位置!)契约 ===
//
// 词条卡片和落字回退卡片都不是自己的窗口 —— 它们借用录音胶囊那一个 "capsule"
// 窗口,弹出时把它缩到卡片大小、挪到右下角。收起时必须原样还回去。
//
// 「还位置」这一步曾经漏过一次,真机表现是:用过一次带添加词的卡片之后,
// 下一次录音的胶囊出现在右下角,再也回不到底部居中。漏这一步之所以致命,
// 是因为 maybe_position_capsule_bottom_center 的去重缓存只记「显示器 + 翻译态」,
// 卡片这一挪它一无所知 —— 下次录音拿到相同的显示器快照就判定「没变化」,
// 直接跳过重新定位。窗口被挪走了,而唯一会把它挪回来的那段代码以为自己不用动。
//
// 所以复位和清缓存两件事都要做,各堵一个方向;穿透状态同理(emit_capsule 靠
// capsule_cursor_passthrough 跳过重复调用,缓存与窗口真实状态分家就会跳过
// 该调的那一次,表现是胶囊上的 ✓/✕ 点不动)。
//
// 这段修复在 2026-08 丢过一次(只存在于未合并的本地分支上,主线重新长回了
// 漏位置的版本),靠单测抓不到 —— 它全是 Tauri 窗口调用,跑在 main thread
// 闭包里。契约测试是唯一能钉住它的手段。
const coordinatorRs = (
await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8')
).replace(/\r\n/g, '\n');
function extractFn(source, name) {
const match = source.match(new RegExp(`pub\\(crate\\) fn ${name}[\\s\\S]*?\\n}\\n`));
if (!match) {
throw new Error(`${name}: function not found in coordinator.rs`);
}
return match[0];
}
// 弹卡片 = 把共享窗口挪走,去重缓存必须当场作废。
for (const name of ['show_vocab_suggestion_card', 'show_insert_fallback_card']) {
const body = extractFn(coordinatorRs, name);
assertMatch(
body,
/\*inner\.capsule_layout\.lock\(\) = None;/,
`${name} moves the shared capsule window, so it must invalidate the capsule_layout dedup cache`,
);
assertMatch(
body,
/capsule_cursor_passthrough\s*\.store\(false, Ordering::SeqCst\)/,
`${name} calls set_ignore_cursor_events directly, so it must keep capsule_cursor_passthrough in sync`,
);
}
// 收卡片 = 把窗口完整还回去:穿透、尺寸、位置,一样都不能少。
for (const name of ['hide_vocab_suggestion_card', 'hide_insert_fallback_card']) {
const body = extractFn(coordinatorRs, name);
assertMatch(
body,
/set_ignore_cursor_events\(true\)/,
`${name} must restore cursor passthrough, or the capsule keeps blocking that strip of screen`,
);
assertMatch(
body,
/capsule_cursor_passthrough\s*\.store\(true, Ordering::SeqCst\)/,
`${name} must keep the capsule_cursor_passthrough cache in sync with the window it just touched`,
);
assertMatch(
body,
/capsule_window_bounds\(false\)[\s\S]*?set_size/,
`${name} must restore the capsule window size, or the next capsule is squeezed into a card-sized window`,
);
assertMatch(
body,
/\*inner\.capsule_layout\.lock\(\) = None;/,
`${name} must invalidate the capsule_layout dedup cache, or the next recording skips repositioning and the capsule stays bottom-right`,
);
assertMatch(
body,
/position_capsule_bottom_center\(&window, false\)/,
`${name} must move the capsule window back to bottom-center; restoring size alone leaves it in the card's bottom-right corner`,
);
// 顺序不变量:尺寸和位置要一起动,窗口还亮着时改就有概率被合成出一帧
//「卡片被拉宽、还横着飞过半个屏幕」。
const hideAt = body.indexOf('window.hide()');
const resizeAt = body.indexOf('set_size');
if (hideAt === -1 || hideAt > resizeAt) {
throw new Error(
`${name} must hide the window before changing its geometry, or the restore animates on screen`,
);
}
}
+31 -2
View File
@@ -253,12 +253,23 @@ pub(crate) fn show_vocab_suggestion_card(inner: &Arc<Inner>) {
if let Err(e) = window.set_ignore_cursor_events(false) {
log::warn!("[vocab-card] set_ignore_cursor_events(false) failed: {e}");
}
// 穿透状态也是有缓存的(`capsule_cursor_passthrough`,emit_capsule 靠它跳过
// 重复调用)。这里直接碰了窗口就必须同步那个缓存,否则它记着的值和窗口
// 真实状态分家,下次 emit_capsule 会以为「没变化」而跳过该调的那一次。
#[cfg(not(mobile))]
inner
.capsule_cursor_passthrough
.store(false, Ordering::SeqCst);
if let Err(e) = window.set_size(tauri::LogicalSize::new(VOCAB_CARD_WIDTH, height)) {
log::warn!("[vocab-card] resize failed: {e}");
}
if let Err(e) = position_vocab_card(&window, VOCAB_CARD_WIDTH, height) {
log::warn!("[vocab-card] position failed: {e}");
}
// 位置同理:`maybe_position_capsule_bottom_center` 的去重缓存只记「显示器 +
// 翻译态」,卡片这一挪它一无所知。不清掉的话,下一次录音时它会拿相同的
// 显示器快照判定「没变化」→ 跳过重新定位 → 胶囊留在卡片挪过去的右下角。
*inner.capsule_layout.lock() = None;
let _ = app.emit_to("capsule", "vocab:suggested", &pending);
show_capsule_window_for_recording(&app, &window, true);
#[cfg(target_os = "macos")]
@@ -282,24 +293,42 @@ pub(crate) fn hide_vocab_suggestion_card(inner: &Arc<Inner>) {
return;
};
let app_for_main = app.clone();
let inner_for_main = Arc::clone(inner);
let _ = app.run_on_main_thread(move || {
let app = app_for_main;
let inner = inner_for_main;
let Some(window) = app.get_webview_window("capsule") else {
return;
};
let _ = app.emit_to("capsule", "vocab:suggested", Vec::<crate::types::PendingCorrection>::new());
// 先隐藏再改几何:复原要同时动尺寸和位置,窗口还亮着时改就有概率被合成出
// 一帧「卡片被拉宽、还横着飞过半个屏幕」。
let _ = window.hide();
// 穿透必须还回去,否则胶囊会一直挡着屏幕底部那一块。
#[cfg(not(mobile))]
if let Err(e) = window.set_ignore_cursor_events(true) {
log::warn!("[vocab-card] restoring cursor passthrough failed: {e}");
}
#[cfg(not(mobile))]
inner
.capsule_cursor_passthrough
.store(true, Ordering::SeqCst);
// 尺寸也必须还回去 —— 卡片把窗口缩到过自己的大小,不复原的话下一次胶囊
// 就挤在一个 300×108 的窗口里,等于看不见。
// 就挤在一个 320×108 的窗口里,等于看不见。
let bounds = crate::capsule_window_bounds(false);
if let Err(e) = window.set_size(tauri::LogicalSize::new(bounds.width, bounds.height)) {
log::warn!("[vocab-card] restoring capsule size failed: {e}");
}
let _ = window.hide();
// 位置一样要还 —— 卡片把窗口挪到了右下角,胶囊的位置是底部居中。
// 只还尺寸不还位置,下一次录音胶囊就出现在右下角(真机上就是这个 bug)。
//
// 清缓存和这次重定位是两件事,都要做:清缓存保证「就算这次重定位失败,
// 下一次 emit_capsule 也一定会重算」,重定位保证「就算有哪条路径绕过了
// emit_capsule 直接 show,窗口也已经在对的地方」。
*inner.capsule_layout.lock() = None;
if let Err(e) = crate::position_capsule_bottom_center(&window, false) {
log::warn!("[vocab-card] restoring capsule position failed: {e}");
}
});
}