From 0fbf1a00bfd20d01729cd0977b822975ecee7701 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 19 Jun 2026 19:26:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(capsule):=20=E8=83=B6=E5=9B=8A=E8=B7=9F?= =?UTF-8?q?=E9=9A=8F=E9=BC=A0=E6=A0=87=E5=85=89=E6=A0=87=E6=89=80=E5=9C=A8?= =?UTF-8?q?=E5=B1=8F=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=A4=9A=E5=B1=8F/?= =?UTF-8?q?=E5=A4=9A=20Space=20=E5=8F=AA=E5=9C=A8=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E5=9D=97=E5=B1=8F=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多屏 + 多桌面下胶囊只出现在第一块屏、其他屏闪一下就消失。根因:定位 (position_capsule_bottom_center) 用 AX caret 选屏,多数 App 取不到 caret 矩形就 跌回胶囊自己的 current_monitor(第一块屏);而去重缓存 capsule_layout_snapshot 也 用 current_monitor,与定位看的不是同一块屏 → 胶囊一落第一块屏即被判“无变化”跳过 重定位,被锁死。两条路都没跟踪鼠标。 - 新增 mouse_cursor_point():CoreGraphics CGEventGetLocation 取光标,坐标系与 AX 一致、零权限、永远可用 - focused_input_target_monitor → capsule_target_monitor,选屏改“光标优先”(caret 仅兜底),选屏循环抽成 monitor_for_anchor_point - 定位与去重快照统一走 capsule_target_monitor,彻底消除不一致 - 保留 CAN_JOIN_ALL_SPACES(Spaces 契约强制且本就正确:帧落在光标屏→只那块屏显示) - 扩展 macos-capsule-spaces 契约测试,grep 守护“光标优先 + 两路共用同一函数” Windows 路径未动(本就跟随输入 App 屏且快照一致)。 验证:cargo check + lib 纯函数单测 + check:macos-capsule-spaces 全过。 --- .../macos-capsule-spaces-contract.test.mjs | 28 ++++++++ .../src/coordinator/capsule_focus.rs | 25 +++++-- openless-all/app/src-tauri/src/lib.rs | 66 ++++++++++++++++--- 3 files changed, 105 insertions(+), 14 deletions(-) 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 ef327c70..f4963a4b 100644 --- a/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs +++ b/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs @@ -47,3 +47,31 @@ for (const forbidden of ['window.show()', 'set_focus', 'NSApp.activate', 'makeKe throw new Error(`macOS capsule no-activate path must not call ${forbidden}`); } } + +// === 胶囊跟随「鼠标光标所在屏」契约(多屏 / 多 Space)=== +// 根因:定位用 AX caret、layout 去重缓存却用胶囊自己的 current_monitor,两者看 +// 不同的屏;光标移到另一块屏时缓存误判「没变化」→ 跳过重新定位 → 胶囊被锁死 +// 在第一块屏(别屏只闪一下)。修复后两条路径必须共用 capsule_target_monitor, +// 且以鼠标光标为首选信号。这些不变量纯靠源码 grep 守护,无法在无多屏硬件的 +// 单测里覆盖,正是契约测试的用武之地。 +const libRs = ( + await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf-8') +).replace(/\r\n/g, '\n'); + +assertMatch( + libRs, + /fn capsule_target_monitor[\s\S]*?macos_mouse_cursor_point\(\)\s*\.or_else\(\s*macos_focused_input_anchor_point\s*\)/, + 'macOS capsule must resolve its target monitor from the mouse cursor first, AX caret only as fallback', +); + +assertMatch( + libRs, + /跟随鼠标光标所在显示器[\s\S]*?if let Some\(mon\) = capsule_target_monitor\(window\)/, + 'macOS capsule positioning must follow capsule_target_monitor (the mouse screen), not its own current_monitor', +); + +assertMatch( + capsuleFocusRs, + /#\[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', +); diff --git a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs index 535526a7..5b7c7ddc 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -590,10 +590,10 @@ pub(super) struct CapsuleLayoutState { /// 返回胶囊「应该摆放到的显示器」的标识信息。 /// /// 它看的显示器必须和 `position_capsule_bottom_center` 实际定位用的一致: -/// Windows 看「正在输入的 App 所在显示器」,其它平台看胶囊自己的显示器。 -/// 这是「是否需要重新定位」去重缓存(`maybe_position_capsule_bottom_center`) -/// 的 key,如果这里看错了显示器,就会出现「输入焦点移到另一块屏、胶囊却没 -/// 跟过去」的 bug。 +/// Windows 看「正在输入的 App 所在显示器」,macOS 看「鼠标光标所在显示器」, +/// 其它平台看胶囊自己的显示器。这是「是否需要重新定位」去重缓存 +/// (`maybe_position_capsule_bottom_center`)的 key,如果这里看错了显示器, +/// 就会出现「焦点/光标移到另一块屏、胶囊却没跟过去」的 bug。 pub(super) fn capsule_layout_snapshot( window: &tauri::WebviewWindow, translation_active: bool, @@ -615,6 +615,23 @@ pub(super) fn capsule_layout_snapshot( } // 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor。 } + // macOS:以「鼠标光标所在显示器」为基准,必须和 + // position_capsule_bottom_center 实际定位用的同一块屏;否则光标移到另一块 + // 屏时这里仍读到胶囊旧屏 → 误判「没变化」→ 跳过重新定位 → 胶囊锁死在第一块屏。 + #[cfg(target_os = "macos")] + { + if let Some(mon) = crate::capsule_target_monitor(window) { + return Some(CapsuleLayoutState { + translation_active, + monitor_x: mon.physical_x, + monitor_y: mon.physical_y, + monitor_width: mon.physical_width, + monitor_height: mon.physical_height, + scale_bits: mon.scale.to_bits(), + }); + } + // 取不到光标 / AX 位置时落回下面的 current_monitor。 + } let monitor = window.current_monitor().ok().flatten()?; Some(CapsuleLayoutState { translation_active, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 0b449b71..8b75b5df 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1574,17 +1574,34 @@ impl CapsuleTargetMonitor { } } -/// macOS:把「当前 focused input / caret」映射到显示器。 +/// macOS:决定胶囊应该摆到哪块显示器。 /// -/// 不能用 capsule window 的 current_monitor:窗口隐藏时它仍停留在上一次出现的屏, -/// 多屏输入会因此被缓存误判为“不需要移动”。这里先用 AX 取 caret/输入框位置, -/// 再在 Tauri 的 monitor 坐标系里选包含该点的屏;如果点短暂落在所有屏外, -/// 退到最近的屏,避免虚拟桌面负坐标/屏幕排列边缘导致完全不显示。 +/// 跟随**鼠标光标所在的屏**——这是用户的操作/视线焦点,也是唯一始终可用、 +/// 无需任何权限的信号,多显示器 + 多 Space 下都能稳定命中。光标取不到时 +/// (理论上不会)才退回 AX focused-input/caret 位置。 +/// +/// 关键:不能用 capsule window 自己的 current_monitor——窗口隐藏时它仍停留在 +/// 上一次出现的屏,多屏会被缓存误判为“不需要移动”,把胶囊锁死在第一块屏。 +/// 选屏时先找包含该点的屏;点短暂落在所有屏外则退到最近的屏,避免虚拟桌面 +/// 负坐标 / 屏幕排列边缘导致完全不显示。定位与 layout 去重缓存共用本函数, +/// 二者看的必须是同一块屏。 +#[cfg(target_os = "macos")] +pub(crate) fn capsule_target_monitor( + window: &tauri::WebviewWindow, +) -> Option { + let (x, y) = macos_mouse_cursor_point().or_else(macos_focused_input_anchor_point)?; + monitor_for_anchor_point(window, x, y) +} + +/// 在 Tauri 的 monitor 坐标系里,选出包含逻辑坐标点 `(x, y)` 的显示器; +/// 点落在所有屏之外时退到最近的屏。坐标系同 AX / CGEvent 的全局显示空间 +/// (左上原点,points)。 #[cfg(target_os = "macos")] -pub(crate) fn focused_input_target_monitor( +fn monitor_for_anchor_point( window: &tauri::WebviewWindow, + x: f64, + y: f64, ) -> Option { - let (x, y) = macos_focused_input_anchor_point()?; let monitors = window.available_monitors().ok()?; let mut nearest: Option<(f64, CapsuleTargetMonitor)> = None; @@ -1610,6 +1627,11 @@ pub(crate) fn focused_input_target_monitor( nearest.map(|(_, target)| target) } +#[cfg(target_os = "macos")] +fn macos_mouse_cursor_point() -> Option<(f64, f64)> { + macos_capsule_ax::mouse_cursor_point() +} + #[cfg(target_os = "macos")] fn macos_focused_input_anchor_point() -> Option<(f64, f64)> { macos_capsule_ax::focused_input_anchor_point() @@ -1695,6 +1717,30 @@ mod macos_capsule_ax { } } + type CGEventRef = *const c_void; + type CGEventSourceRef = *const c_void; + + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGEventCreate(source: CGEventSourceRef) -> CGEventRef; + fn CGEventGetLocation(event: CGEventRef) -> CGPoint; + } + + /// 当前鼠标光标在「全局显示坐标系」(左上原点,points) 的位置。该坐标系与 AX + /// caret 完全一致,可直接拿去和 `logical_frame` 比较选屏。`CGEventGetLocation` + /// 始终可用、不需要任何权限,所以作为胶囊跟随屏幕的首选信号。 + pub(super) fn mouse_cursor_point() -> Option<(f64, f64)> { + unsafe { + let event = CGEventCreate(std::ptr::null()); + if event.is_null() { + return None; + } + let point = CGEventGetLocation(event); + CFRelease(event as CFTypeRef); + Some((point.x, point.y)) + } + } + unsafe fn cfstring_from_static(bytes_with_nul: &[u8]) -> Option { let cstr = CStr::from_bytes_with_nul(bytes_with_nul).ok()?; let s = CFStringCreateWithCString( @@ -2486,11 +2532,11 @@ pub(crate) fn position_capsule_bottom_center( // 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor 逻辑。 } - // macOS:跟随当前 focused input / caret 所在显示器,而不是胶囊窗口 - // 上一次停留的显示器。这样外接屏上输入时,隐藏态胶囊也能先移动再出现。 + // macOS:跟随鼠标光标所在显示器,而不是胶囊窗口上一次停留的显示器。 + // 这样在任意外接屏 / 任意 Space 上触发时,隐藏态胶囊也能先移动再出现。 #[cfg(target_os = "macos")] { - if let Some(mon) = focused_input_target_monitor(window) { + if let Some(mon) = capsule_target_monitor(window) { window.set_size(LogicalSize::new(bounds.width, bounds.height))?; let frame = mon.logical_frame(); let (x, y) = bottom_visual_position(