fix(capsule): 胶囊跟随鼠标光标所在屏,修复多屏/多 Space 只在第一块屏显示 (#714)
新增 CGEventGetLocation 取鼠标光标位置(零权限),focused_input_target_monitor → capsule_target_monitor,定位与去重快照统一走光标优先选屏,消除「定位用 AX caret、去重用 current_monitor」两套选屏不一致导致的锁屏。 Claude 审核:5 个核心确认点(坐标系一致/选屏逻辑/快照统一/零权限/平台隔离)全部通过,可合并。
This commit is contained in:
3 files changed
+105
-14
No files matched your search
@@ -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',
|
||||
);
|
||||
@@ -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<R: tauri::Runtime>(
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
translation_active: bool,
|
||||
@@ -615,6 +615,23 @@ pub(super) fn capsule_layout_snapshot<R: tauri::Runtime>(
|
||||
}
|
||||
// 仅当 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,
|
||||
|
||||
@@ -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 focused_input_target_monitor<R: tauri::Runtime>(
|
||||
pub(crate) fn capsule_target_monitor<R: tauri::Runtime>(
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
) -> Option<CapsuleTargetMonitor> {
|
||||
let (x, y) = macos_focused_input_anchor_point()?;
|
||||
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")]
|
||||
fn monitor_for_anchor_point<R: tauri::Runtime>(
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<CapsuleTargetMonitor> {
|
||||
let monitors = window.available_monitors().ok()?;
|
||||
let mut nearest: Option<(f64, CapsuleTargetMonitor)> = None;
|
||||
|
||||
@@ -1610,6 +1627,11 @@ pub(crate) fn focused_input_target_monitor<R: tauri::Runtime>(
|
||||
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<CFStringRef> {
|
||||
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<R: tauri::Runtime>(
|
||||
// 仅当 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(
|
||||
|
||||
Reference in new issue
Block a user