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:
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user