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`, + ); + } +} 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}"); + } }); }