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
84 changes: 84 additions & 0 deletions openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
}
}
33 changes: 31 additions & 2 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,12 +242,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")]
Expand All @@ -271,24 +282,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}");
}
});
}

Expand Down
Loading