【免费下载链接】markeron
Lightweight (~1.5 MB) open-source screen annotation with click-through mode and keyboard-first shortcuts. For demos, teaching, meetings & screen recording. Windows, macOS.
MarkerOn 是一款仅约 1.5 MB 的开源屏幕标注工具,按下 Ctrl+Shift+D 即可在整个桌面上绘画、高亮、做标记。它的核心是一块覆盖全屏的透明覆盖层(Overlay)窗口——既不能遮挡鼠标操作,又不能出现恼人的黑边或白框。这篇文章带你拆解 MarkerOn 是如何在 win32 与 macOS 上实现这块透明覆盖层的,涉及 DWM 透明、子窗口堆叠、光标禁锢等完整窗口技术细节。
屏幕标注覆盖层的三大难题
做一个"画在所有应用之上"的标注层,表面上就是建一个无边框透明窗口,实际上要同时解决三个问题:
- 真透明:窗口背景必须全透明,且不能在使用中"变黑变白"——WebView 内核(WebView2 / WKWebView)的渲染器会因空闲、GPU 回收、DPI 变化等原因悄悄丢掉透明状态;
- 置顶与点透:窗口必须永远浮在最上层,又要能随时把鼠标"放穿"给下面的应用(click-through 穿透模式);
- 多屏与边缘细节:覆盖层要精确贴合当前鼠标所在的显示器,还不能惊动任务栏、不能破坏浏览器全屏。
MarkerOn 的后端用 Rust + Tauri 实现,平台差异全部隔离在独立模块里:
| 模块 | 职责 |
|---|---|
| src-tauri/src/overlay.rs | 覆盖层/工具栏窗口生命周期与三态状态机(跨平台) |
| src-tauri/src/win32.rs | Windows 平台:DWM 透明、置顶、全屏判定 |
| src-tauri/src/macos.rs | macOS 平台:AppKit 子窗口、键窗口管理 |
| src-tauri/src/macos_cursor.rs | macOS 光标禁锢(轮询 + 回弹) |
| src-tauri/src/video_compat.rs | Windows 录屏/浏览器全屏兼容 |
| src-tauri/src/monitor.rs | 显示器几何、光标边界(跨平台) |
总体架构:两个窗口 + 三态状态机
覆盖层与工具栏在 tauri.conf.json 中声明:overlay窗口设置transparent: true、decorations: false、alwaysOnTop: true、skipTaskbar: true,初始隐藏。
后端用一个简单的状态机驱动所有行为(overlay.rs):
- Hidden:窗口隐藏,只驻留托盘;
- Drawing:覆盖层接管鼠标,可以画笔/荧光笔/箭头等 11 种工具标注;
- Penetration:穿透模式,覆盖层保持可见但鼠标事件放穿给下层应用。
一次激活的完整流程在 activate_drawing() 中:先按当前鼠标所在显示器定位窗口 → 重新断言透明 →show()→ 设为置顶 → 通知前端 → 把工具栏抬到覆盖层之上。
焦点丢失时自动进入穿透模式
一个容易忽略的细节:Drawing 状态下用户点击了别的窗口,覆盖层会丢失焦点。MarkerOn 没有直接退出,而是延迟 120ms 确认后自动切入 Penetration 模式(on_overlay_focus_lost()),并配合 600ms 的"激活保护期"防止刚激活时误触发。这样标注画面保留,鼠标却已经可以操作下面的应用了。
Windows 实现:DWM 透明与"1 像素"技巧
双层透明:DWM 宿主窗口 + WebView2 背景
Windows 上的透明是"两层"的:宿主窗口靠 DWM 合成器,WebView2 内容靠自己的 Alpha 合成器。tao只在窗口创建时调用过一次DwmEnableBlurBehindWindow,而 DWM 在长时间空闲(尤其覆盖层带着WS_EX_LAYERED穿透样式时)可能悄悄丢掉这个状态,留下一片黑底。
MarkerOn 的对策是"每次激活都重新断言"(reapply_overlay_transparency()):
- 重新调用
DwmEnableBlurBehindWindow,传入一个空模糊区域,让宿主窗口完全透明; - 同时调用 Tauri 的
set_background_color(0,0,0,0),重新设置 WebView2 的DefaultBackgroundColor。
此外,每次窗口重新获得焦点、以及 DPI 缩放变化时(lib.rs)都会再次执行这套断言,因为 WebView2 的 GPU 进程可能在这些时刻重建渲染器。
2 秒脉冲重断言:对抗 GPU 回收
DPI 变化或显示器移动可能触发 WebView2 完全重建合成器,而重建时间可能远超一次修复。notify_overlay_geometry_changed()(overlay.rs)会启动一条脉冲序列,在 50ms / 150ms / 500ms / 1000ms / 2000ms 各断言一次透明并重发几何变更事件,确保"黑底闪回"在任何时刻都能被兜住。
置顶与"1 像素"技巧
置顶通过SetWindowPos(HWND_TOPMOST)实现,但有一个精妙之处——position_window_on_monitor() 默认让窗口高度比显示器小 1 像素。因为 Windows 11 会把"恰好铺满一块屏幕的顶层窗口"当作全屏应用处理,从而剥离任务栏的 Mica 透明效果,导致任务栏闪烁。
反过来,如果你正在另一个全屏应用(浏览器 F11、视频)上启动标注,这个 1px 缝隙又会导致 Explorer 把任务栏抬到覆盖层之上。于是 MarkerOn 先用 fullscreen_foreground_window() 检测前台窗口是否铺满当前显示器(排除桌面、带标题栏窗口等),若命中则cover_monitor = true铺满整屏,并用 show_covering_window_without_activation() 以SW_SHOWNA显示后再连续两次SetWindowPos改尺寸——因为 Windows 只在可见窗口被调整大小时才重新计算"是否全屏",而隐藏状态下改尺寸会留下过期的判定,导致首次激活瞬间任务栏窜到全屏应用上面。
退出标注时,deactivate_drawing() 还会把前台焦点还给原本被覆盖的全屏应用(restore_foreground_window()),并只在全屏应用确实被覆盖层持有焦点时才切换,绝不偷走其他窗口的焦点。
光标禁锢与录屏兼容
- 光标禁锢:进入 Drawing 后用
ClipCursor把系统光标限制在当前标注的显示器内(remember_and_clip_drawing_monitor()),防止你画着画着鼠标跑到第二块屏幕;穿透模式临时解除。 - 录屏兼容:覆盖层通过
SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)把自己从BitBlt截图中排除(win32.rs);同时 video_compat.rs 通过窗口子类化,在样式被改写时始终保留WS_EX_TOOLWINDOW标志——Chromium 会把工具窗口排除在原生遮挡检测之外,浏览器视频才不会因覆盖层"遮挡"而暂停。
macOS 实现:子窗口堆叠与键窗口管理
工具栏不是"平级窗口",而是覆盖层的"孩子"
macOS 上最棘手的问题是堆叠顺序:工具栏和覆盖层都设为always_on_top时,点一下画布就可能把覆盖层抬到工具栏之上,点一下面板又可能反过来。ignoresMouseEvents这类平级窗口 z-order 方案不可靠。
MarkerOn 的解法是直接利用 AppKit 的父子窗口关系(order_toolbar_above_overlay_now()):调用addChildWindow:ordered:把工具栏挂成覆盖层的子窗口,排在NSWindowAbove。子窗口永远压在父窗口之上,画布点击怎么重排都不会把墨迹画到面板上。代码注释里还留了一条红线:只允许调用 NSWindow 的选择器,绝不能碰 WKWebView / WryWebView 的 ObjC 接口(会直接崩溃)。
点击穿透:用"键窗口"切换代替 ignoresMouseEvents
既然工具栏是子窗口,它天然赢得命中测试,不需要再对父覆盖层开ignoresMouseEvents(旧方案反而会让子窗口收不到 hover)。现在的做法很优雅(set_overlay_ignore_cursor_events()):
- 指针进入面板 →
activate_toolbar_for_pointer_interaction()让工具栏成为 key window 并开启 mouse-moved 事件,hover 高亮、按钮无需先"激活点击"就能用; - 指针离开面板 →
activate_overlay_for_drawing()把 key 身份还给覆盖层,继续收画笔事件。
两个窗口都固定在NSFloatingWindowLevel(即 Taurialways_on_top对应的层级),真正的堆叠交给子窗口关系负责。
没有 ClipCursor:8ms 轮询 + 光标回弹
macOS 没有公开的ClipCursorAPI。MarkerOn 在 macos_cursor.rs 里起了一个独立线程,每 8ms 用CGEventCreate+CGEventGetLocation采样一次光标位置,一旦越出标注显示器的边界,就调用CGWarpMouseCursorPosition把光标"弹"回边界内。效果与 Windows 的ClipCursor一致,但实现是轮询式硬约束。
截屏排除与 Accessory 激活策略
- 截屏排除:工具栏调用
setSharingType: NSWindowSharingNone(macos.rs),与 Windows 的WDA_EXCLUDEFROMCAPTURE对应——面板不会出现在"复制白板"的截图里; - 无 Dock 图标:应用以
ActivationPolicy::Accessory运行(纯托盘/菜单栏应用),只有打开设置窗口时才临时切到Regular,关闭后恢复(macos.rs)。
两端差异速查表
| 能力 | Windows (win32) | macOS (AppKit) |
|---|---|---|
| 窗口透明 | DwmEnableBlurBehindWindow空区域 + WebView2 背景色 | Wry 自动禁用白底 +set_background_color(0,0,0,0) |
| 永远置顶 | SetWindowPos(HWND_TOPMOST) | setLevel:NSFloatingWindowLevel |
| 工具栏压住墨迹 | 顶层组内重排 Z-order | addChildWindow:ordered:子窗口 |
| 点击穿透 | WS_EX_TRANSPARENT | 子窗口天然胜命中测试 + key 窗口切换 |
| 光标禁锢 | ClipCursor系统 API | 8ms 轮询 +CGWarpMouseCursorPosition |
| 截屏排除 | WDA_EXCLUDEFROMCAPTURE | NSWindowSharingNone |
| 几何单位 | 物理像素 | 逻辑点(points) |
写在最后
MarkerOn 的透明覆盖层没有任何"魔法",靠的是一系列被踩坑后沉淀下来的防御性细节:1 像素的缝隙、2 秒的脉冲重断言、只在焦点真正被覆盖层持有时才归还前台、子窗口替代 z-order 博弈。这些技巧对任何想做悬浮工具、录屏标注、白板应用的开发者都是直接可复用的。
想继续深入,建议从 src-tauri/src/overlay.rs 的状态机读起,再对照 src-tauri/src/win32.rs 和 src-tauri/src/macos.rs 看两端实现的分叉点;README.md 里有完整的快捷键清单与功能说明,README_zh.md 为中文版。
【免费下载链接】markeron
Lightweight (~1.5 MB) open-source screen annotation with click-through mode and keyboard-first shortcuts. For demos, teaching, meetings & screen recording. Windows, macOS.
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考