Skip to content

fix(capsule): 胶囊跟随鼠标光标所在屏,修复多屏/多 Space 只在第一块屏显示#714

Merged
appergb merged 1 commit into
betafrom
fix/capsule-multiscreen-cursor
Jun 20, 2026
Merged

fix(capsule): 胶囊跟随鼠标光标所在屏,修复多屏/多 Space 只在第一块屏显示#714
appergb merged 1 commit into
betafrom
fix/capsule-multiscreen-cursor

Conversation

@appergb

@appergb appergb commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

User description

问题

多屏 + 多桌面(Spaces)下,听写胶囊只出现在第一块屏,其他屏只闪一下就消失。期望:胶囊出现在鼠标当前所在的屏/桌面,其他屏不显示。

根因(macOS)

胶囊「该摆哪块屏」用了两套互相矛盾的判断:

  1. 定位 position_capsule_bottom_centerfocused_input_target_monitor(AX 输入框 caret 位置)。多数 App 取不到可用的 AX 矩形 → 返回 None → 跌落 window.current_monitor()(胶囊自己上次停留的屏 = 第一块屏)。
  2. 去重缓存 capsule_layout_snapshot 在 macOS 也用 window.current_monitor()和定位看的不是同一块屏。两者不一致 → 胶囊一旦落到第一块屏,之后每次状态变化都判 snapshot == last跳过重新定位,被锁死在第一块屏;切换时在别屏短暂出现 = 那个「闪一下」。

两条路都没有跟踪鼠标光标——而这正是用户要的、也是唯一「永远可用、零权限」的信号。capsule_focus.rs 自己的注释(590–596 行)早就警告过这两者必须一致。

修法

  • 新增 macos_capsule_ax::mouse_cursor_point():CoreGraphics CGEventCreate+CGEventGetLocation 取光标位置,与 AX caret 同属 Quartz 全局显示坐标系(左上原点、points),可直接复用现有 logical_frame/frame_contains_point 选屏;零权限、始终可用。
  • focused_input_target_monitorcapsule_target_monitor,选屏改光标优先mouse_cursor_point().or_else(focused_input_anchor_point)),选屏循环抽成纯函数 monitor_for_anchor_point
  • 定位去重快照统一走 capsule_target_monitor,彻底消除不一致 → 不再锁死第一块屏、不再闪现,胶囊跟随鼠标所在的屏/桌面。
  • 保留 CAN_JOIN_ALL_SPACES:Spaces 契约测试强制,且本就正确——窗口帧落在光标那块屏 → 只那块屏显示,同时跨 Space 跟随(满足「鼠标所在桌面才显示」)。
  • 扩展 macos-capsule-spaces 契约测试,新增 3 条源码 grep 守护本次不变量(光标优先、定位走 capsule_target_monitor、快照复用同一函数),防止回归成「测试绿、线上坏」。

坐标系坑:NSEvent.mouseLocation 是左下原点 Cocoa 系(需翻转,未采用);CGEventGetLocation 是左上原点、免翻转,与 AX 对齐。

Windows 路径未改动——它本就走 foreground_window_monitor(跟随正在输入的 App 所在屏),且定位与快照一致。

测试

  • cargo check --manifest-path src-tauri/Cargo.toml(仅既有 warning)
  • lib 纯函数单测(monitor / frame / clamp 共 18 项)全绿
  • npm run check:macos-capsule-spaces(含新增 3 条契约)通过
  • 真机多屏 + 多桌面(桌面 1~4)逐屏触发听写,确认胶囊出现在鼠标所在屏、其他屏不闪现(已出 1.3.10 ad-hoc dmg 自测)

🤖 Generated with Claude Code


PR Type

Bug fix, Enhancement


Description

  • Capsule now follows mouse cursor across multi-screen/Spaces

  • Unified monitor detection: both positioning and layout cache use same function

  • Added contract tests to guard invariants


Diagram Walkthrough

flowchart LR
  MouseCursor["Mouse Cursor (CGEvent)"] --> capsuleTargetMonitor["capsule_target_monitor()"]
  AXcaret["AX Caret (fallback)"] --> capsuleTargetMonitor
  capsuleTargetMonitor --> positioning["position_capsule_bottom_center"]
  capsuleTargetMonitor --> layoutCache["capsule_layout_snapshot"]
  layoutCache --> reposition["Skip reposition if no change"]
Loading

File Walkthrough

Relevant files
Bug fix
capsule_focus.rs
Add macOS monitor detection to layout cache                           

openless-all/app/src-tauri/src/coordinator/capsule_focus.rs

  • Added macOS branch in capsule_layout_snapshot to use
    capsule_target_monitor
  • Ensures layout cache and positioning use the same monitor
+21/-4   
Enhancement
lib.rs
Introduce mouse cursor based monitor selection                     

openless-all/app/src-tauri/src/lib.rs

  • Renamed focused_input_target_monitor to capsule_target_monitor
  • Added macos_mouse_cursor_point() using CoreGraphics (zero-permission)
  • Positioning falls back to AX caret if mouse cursor unavailable
  • Updated comments and logic
+56/-10 
Tests
macos-capsule-spaces-contract.test.mjs
Add contract tests for capsule monitor selection                 

openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs

  • Added contract tests to verify mouse cursor priority and shared
    function
  • Uses grep on source code
+28/-0   

多屏 + 多桌面下胶囊只出现在第一块屏、其他屏闪一下就消失。根因:定位
(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 全过。
@github-actions

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ No major issues detected

@appergb

appergb commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

@claude 请审核(多屏胶囊定位):本 PR 修复「胶囊只在第一块屏/多 Space 只显示一次」。根因是定位用 AX caret 选屏、但去重缓存 capsule_layout_snapshot 在 macOS 看 window.current_monitor()(胶囊上次停留的屏),光标移到别屏时缓存误判「无变化」跳过重定位,胶囊锁死在第一块屏。修法:新增 CGEventGetLocation 取鼠标光标位置(零权限),focused_input_target_monitor → capsule_target_monitor,定位与去重统一走光标优先选屏。

重点请确认:

  1. CGEventGetLocation 取到的坐标系与 AX caret 是否一致(CGEvent 左上 origin,无需翻转);勿混入 NSEvent 左下 origin;
  2. capsule_target_monitor 光标优先选屏逻辑是否正确,找不到光标时回退是否合理;
  3. 定位快照与去重快照是否真正统一到同一选屏来源,确认不再出现锁屏;
  4. CGEventGetLocation 零权限假设是否成立(不触发 TCC);
  5. 是否仅影响 macOS、对 Windows/Linux/Android 路径零改动。

@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown

Claude finished @appergb's task in 2m 42s —— View job


代码审核结果(多屏胶囊定位)

审核清单:

  • 读取变更文件(lib.rscapsule_focus.rsmacos-capsule-spaces-contract.test.mjs
  • 确认 1:CGEventGetLocation 坐标系与 AX caret 一致性
  • 确认 2:capsule_target_monitor 光标优先选屏逻辑正确性
  • 确认 3:定位与去重快照统一到同一选屏来源
  • 确认 4:CGEventGetLocation 零权限假设(TCC)
  • 确认 5:仅影响 macOS、Windows/Linux/Android 路径零改动

确认 1:坐标系一致性 ✅

结论:CGEventGetLocation 与 AX caret 坐标系完全一致,无需翻转。

两者都使用 Quartz 全局显示坐标系(左上原点、Y 向下增大、单位 points):

  • AX API(AXBoundsForRangeAXPosition)→ Quartz 全局坐标系
  • CGEventGetLocation → 同一坐标系

NSEvent.mouseLocation 才是 Cocoa 坐标(左下原点,Y 向上),PR 正确选用了 CGEvent 而非 NSEvent。

坐标系的结合点在 monitor_for_anchor_point

  • logical_monitor_frame 将 Tauri 物理像素坐标(monitor.position()monitor.size())除以 scale_factor 换算成逻辑 points
  • AX / CGEvent 返回的已经是逻辑 points(Retina 不翻倍)
  • frame_contains_point / frame_distance_to_point_squared 直接比较逻辑坐标 ✓

CGPoint 的 FFI 绑定:

#[repr(C)]
struct CGPoint { x: f64, y: f64 }

与 Apple SDK 的 CGPoint = {CGFloat x, CGFloat y}(64-bit 时 CGFloat = double)ABI 完全一致 ✓


确认 2:capsule_target_monitor 选屏逻辑 ✅

结论:逻辑正确,回退合理。

// lib.rs:1592
let (x, y) = macos_mouse_cursor_point().or_else(macos_focused_input_anchor_point)?;
monitor_for_anchor_point(window, x, y)

优先级层次合理:

  1. CGEventCreate(NULL) + CGEventGetLocation — 始终可用,几乎不会返回 NULL
  2. AX caret — 与 PR 修改前行为一致,不引入新的回退风险
  3. 两者都失败 → None → 整体定位走 current_monitor fallback(已有路径)

monitor_for_anchor_point 的选屏策略也正确:

  • 优先: 精确包含点的屏(frame_contains_point
  • 回退: 最近的屏(frame_distance_to_point_squared 最小)

这两步保证了虚拟桌面边缘坐标(如 -1280.0)不会导致返回 None,已有单测验证(monitor_frame_contains_points_with_negative_origins)。

一个轻微 nit(不阻塞合并):macos_mouse_cursor_point() 是对 macos_capsule_ax::mouse_cursor_point() 的单行包装,引入了一层间接。可以直接在 capsule_target_monitor 内调用 macos_capsule_ax::mouse_cursor_point(),但现有写法也无功能问题,保持与 macos_focused_input_anchor_point 对称。


确认 3:定位与去重快照统一 ✅

结论:两处已真正统一到同一函数,锁屏 bug 消除。

修复前的不一致:

路径 旧代码
定位 position_capsule_bottom_center focused_input_target_monitor(AX caret)
去重缓存 capsule_layout_snapshot window.current_monitor()(胶囊上次停留的屏)

修复后的统一:

// lib.rs:2539 — 定位
if let Some(mon) = capsule_target_monitor(window) { ... }

// capsule_focus.rs:618 — 去重缓存
if let Some(mon) = crate::capsule_target_monitor(window) {
    return Some(CapsuleLayoutState { ... });
}

两处调用同一个函数 capsule_target_monitor,返回值包含 monitor_x/y/width/height/scale_bits,作为 CapsuleLayoutState 的 key。光标移到另一块屏时,key 必然变化 → 不再误判「无变化」→ 胶囊正常重定位 ✓

capsule_focus.rs#[cfg(target_os = "macos")] 块在 Windows 的 foreground_window_monitor 块之后、current_monitor fallback 之前插入,控制流顺序正确:macOS 分支命中即直接返回,不会落到 current_monitor


确认 4:CGEventGetLocation 零权限(TCC)✅

结论:不触发 TCC,零权限成立。

CGEventCreate(source: NULL) 创建的是一个自己构造的事件对象,不是截获系统事件流。CGEventGetLocation 从这个对象读取当前光标位置——这是 macOS 公开的合法 API,等价于「查询当前鼠标坐标」,不涉及:

  • Accessibility(kTCCServiceAccessibility
  • Screen Recording(kTCCServiceScreenCapture
  • Input Monitoring(kTCCServiceListenEvent / kTCCServicePostEvent

需要 TCC 的是:CGEventTapCreate(监听/注入全局事件流)、AXUIElement 系列(无障碍访问)。本 PR 新增的只是 CGEventCreate + CGEventGetLocation,不在 TCC 管控范围内。

内存管理亦正确:CGEventCreate 遵循 Create Rule,返回 retain count +1 的对象,用 CFRelease 释放 ✓(CGEventReleaseCFRelease 等价,两者皆可接受)


确认 5:平台隔离 ✅

结论:所有修改仅影响 macOS,其他平台零改动。

  • capsule_target_monitormacos_mouse_cursor_pointmonitor_for_anchor_pointmacos_capsule_ax::mouse_cursor_point 均有 #[cfg(target_os = "macos")]
  • capsule_focus.rs 新增块被 #[cfg(target_os = "macos")] 包裹,Windows 继续走 foreground_window_monitor,其他平台走 window.current_monitor()
  • Windows 的 foreground_window_monitor(跟随正在输入 App 所在屏)未改动 ✓
  • 契约测试 macos-capsule-spaces-contract.test.mjs 是独立脚本,不进入 Android/Windows CI ✓

综合结论

5 个核心确认点全部通过,可合并。 根因分析准确,修法直接消除了不一致来源,契约测试有效守护不变量。

唯一可选改进(不阻塞):

  1. 契约测试正则依赖中文注释(如 跟随鼠标光标所在显示器)——若注释被重写,grep 会误报。可以考虑改为对函数调用模式的纯 Rust 语法匹配,但当前行为对这个 repo 是有意为之(注释即约定),可以接受。

@appergb appergb merged commit cfb09b1 into beta Jun 20, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant