From 19978917dfef5acbad76feb9da69cb3eb1af5f25 Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Wed, 5 Aug 2026 12:00:59 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(vocab-card):=20=E6=94=B6=E8=B5=B7?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=97=B6=E6=8A=8A=E8=83=B6=E5=9B=8A=E7=9A=84?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E4=B9=9F=E8=BF=98=E5=9B=9E=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 卡片和录音胶囊是同一个窗口。弹卡片要把窗口挪到右下角,收卡片时 `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 (cherry picked from commit d3fedcd13da0a3ba002a6f29dda330eeabee62f1) --- openless-all/app/src-tauri/src/coordinator.rs | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index f37ee645..e8a54672 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -242,12 +242,23 @@ pub(crate) fn show_vocab_suggestion_card(inner: &Arc) { 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")] @@ -271,24 +282,42 @@ pub(crate) fn hide_vocab_suggestion_card(inner: &Arc) { 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::::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}"); + } }); } From db78790a8332a0a395f5095b9cc21db6ce058004 Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Wed, 19 Aug 2026 13:34:25 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(capsule):=20=E6=8A=8A=E3=80=8C?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=80=9F=E8=B5=B0=E8=83=B6=E5=9B=8A=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E5=BF=85=E9=A1=BB=E5=AE=8C=E6=95=B4=E5=BD=92=E8=BF=98?= =?UTF-8?q?=E3=80=8D=E9=92=89=E6=88=90=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一条修复(收起卡片时把胶囊位置也还回去)在 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 --- .../macos-capsule-spaces-contract.test.mjs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs b/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs index f4963a4b..4610a9d2 100644 --- a/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs +++ b/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs @@ -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`, + ); + } +}