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
28 changes: 28 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 @@ -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',
);
25 changes: 21 additions & 4 deletions openless-all/app/src-tauri/src/coordinator/capsule_focus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
66 changes: 56 additions & 10 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
) -> Option<CapsuleTargetMonitor> {
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<R: tauri::Runtime>(
fn monitor_for_anchor_point<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
x: f64,
y: f64,
) -> Option<CapsuleTargetMonitor> {
let (x, y) = macos_focused_input_anchor_point()?;
let monitors = window.available_monitors().ok()?;
let mut nearest: Option<(f64, CapsuleTargetMonitor)> = None;

Expand All @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading