macOS 触摸板捏合在 GPUI 里无响应:vendor 打补丁,把手势合成为 cmd+滚轮事件
这两天桌面端 AI 应用越来越多,Mac 用户有个肌肉记忆:双指一捏,画布就该放大;双指一收,就该缩小。结果在我自己维护的开源无限手绘白板 boundless(Rust + GPUI)里,这个手势一度完全没反应------双指在触控板上搓到冒烟,画布纹丝不动。
我没换 UI 框架。我干了另一件事------把 GPUI 0.2.2 的 mac 后端翻了一遍,找到这个手势死在了哪一层,然后给框架打了个 vendor 补丁。
看完我可以负责任地告诉你三件事。第一,捏合手势死在两处:mac 后端的 NSView 根本没注册 magnifyWithEvent: 选择器,系统压根不把事件发进来;就算发进来,事件转换表里也没有这个类型,直接丢弃。第二,修复不新增事件类型------把 Magnify"合成"成一条带 cmd 修饰的像素滚动事件,复用画布现有的"cmd+滚轮=光标处缩放"分支,改动面最小。第三,这种补丁有保质期:升级到 gpui 0.7 时 vendor 目录被删,补丁随 crate 改名失效,得重移植------打补丁时就要写好这笔"技术债借条"。
1. 学习目标:学完你会得到什么
一句话:学完这篇,你会给任何事件驱动的 UI 框架接上 macOS 触摸板捏合缩放------包括"事件死在哪一层"的诊断思路、vendor + [patch.crates-io] 给第三方 crate 打补丁的完整套路、"事件合成"这种改动面最小的打法,以及缩放锚点数学。
先给大家科普两个概念。「NSEventTypeMagnify」:翻译成人话,就是 macOS 把触摸板双指捏合当成一种"手势事件"发给应用,跟滚轮是两种不同的事件类型,它带一个 magnification 字段表示这次捏合的量(正数放大、负数缩小)。「vendor」:把第三方 crate 的源码整个拷进自己仓库,裁掉用不着的部分,然后用 Cargo 的 [patch.crates-io] 把依赖指向本地这份,想怎么改就怎么改。
2. 诊断:手势死在哪两层
出问题先找原因,再动手。捏合无响应,第一反应可能是"我应用层没监听对手势"。但 boundless 的画布本来就有"cmd+滚轮缩放",说明缩放链路是通的。于是顺着事件管线往下查,GPUI 的 mac 事件管线是:AppKit 发 NSEvent → NSView 子类的方法收到 → handle_view_event → PlatformInput::from_native 转成 GPUI 事件 → 应用层的 on_scroll_wheel。
第一层死因在 NSView。GPUI 的 mac 后端用 objc 的 decl.add_method 动态给 NSView 子类注册方法:swipeWithEvent:(三指轻扫)注册了,magnifyWithEvent: 没注册。翻译成人话:系统看你没声明"我要收捏合事件",就根本不发给你。事件在进门之前就死了,应用层监听什么都没用。
第二层死因在 from_native 的转换表。这个函数是个大 match:把 NSEventType 转成 GPUI 的 PlatformInput。表里有鼠标、键盘、滚轮、触摸,就是没有 Magnify;不认识的类型直接 return None 丢弃。就算第一层修好了,事件到这里还是死。
有人可能会问,为什么 GPUI 0.2.2 会漏掉这么常用的手势?我的判断是:GPUI 脱胎于 Zed 编辑器,编辑器场景里捏合缩放不是刚需,触控板手势的优先级一直不高。这不是谁的 bug,是"框架作者的场景"和"你的场景"不一致------这种不一致,只能自己补。
3. 打补丁:vendor 源码 + 两处改动
确定死因后,方案很清晰:vendor 整个 gpui 0.2.2 的 crates.io 源码(裁掉 examples 和 docs,剩 3.1MB,Apache-2.0 协议允许),打两处补丁,然后在 Cargo.toml 里声明:
toml
# Vendored gpui 0.2.2 (crates.io source, pruned examples/docs) with one local
# patch: trackpad pinch (NSEventTypeMagnify) is synthesized into a cmd+scroll
# so the canvas can zoom at the cursor --- stock 0.2.2 drops the gesture.
[patch.crates-io]
gpui = { path = "vendor/gpui" }
注意这个注释的写法:补丁的"为什么"必须写在依赖声明旁边 ,而不是只躺在 commit message 里。三个月后有人看到 [patch.crates-io],第一眼就要知道"这是在干什么、为什么不能删"。
补丁一:window.rs,注册选择器。 在 NSView 子类的声明处加:
rust
// Route trackpad pinch (NSEventTypeMagnify) into the generic
// handler; without this the gesture dies before it can be
// converted by MacEvent::from_native (see events.rs).
decl.add_method(
sel!(magnifyWithEvent:),
handle_view_event as extern "C" fn(&Object, Sel, id),
);
跟旁边的 swipeWithEvent: 一个写法,接到同一个 handle_view_event。门打开了。
补丁二:events.rs,把手势"翻译"成滚轮事件。 这是整个方案最有意思的地方------不新增事件类型,而是合成一条框架已经认识的事件:
rust
// Trackpad pinch arrives as NSEventTypeMagnify --- a gesture type,
// not a scroll event, which stock GPUI 0.2 drops. Synthesize a
// cmd-flagged vertical pixel scroll so hosts can route pinch
// through their existing "cmd+scroll = zoom at cursor" path.
// Scale 500 makes a host zoom factor exp(-dy*k) land on
// exp(magnification) ≈ 1 + magnification, i.e. fingers-true.
NSEventType::NSEventTypeMagnify => window_height.map(|window_height| {
let mag = native_event.magnification() as f32;
Self::ScrollWheel(ScrollWheelEvent {
position: point(
px(native_event.locationInWindow().x as f32),
window_height - px(native_event.locationInWindow().y as f32),
),
delta: ScrollDelta::Pixels(point(px(0.0), px(-mag * 500.0))),
touch_phase: TouchPhase::Moved,
modifiers: {
let mut modifiers = read_modifiers(native_event);
modifiers.platform = true; // 伪装成 cmd 按住
modifiers
},
})
}),
翻译成人话:框架不认识"捏合",但认识"按住 cmd 的滚轮"。那我就把捏合翻译成后者:捏合量 mag 乘以 500 变成纵向像素滚动量,modifiers.platform = true 伪装成 cmd 按住,位置取手势发生点(注意 y 要翻转:AppKit 是左下原点,GPUI 是左上原点)。
为什么是 500?这不是拍脑袋。应用层的缩放公式是 factor = exp(-dy * 0.002),把 dy = -mag * 500 代进去:factor = exp(mag * 500 * 0.002) = exp(mag) ≈ 1 + mag。手指张开 10%(mag=0.1),画面放大约 10%------手指和画面的比例 1:1,注释里叫 "fingers-true"。系数必须和 host 侧公式对齐,否则手感要么迟钝要么窜。
为什么选"合成"而不是"新增 MagnifyEvent 穿透整个框架"?算一笔账:新增事件类型要改 PlatformInput 枚举、所有平台的转换、事件派发、应用层监听------动的是框架的骨架;合成只动 mac 后端的两个函数,应用层零改动(它本来就认识 cmd+滚轮)。能翻译,就别造新词------这是补丁设计的总原则。
4. 应用层:复用 zoom-at-cursor,一套锚点数学
事件翻译完,应用层完全不用动。boundless 的 on_scroll_wheel 本来就有四道守卫:放映模式锁相机(轮缩放大会让邻页漏进来)、设置页打开时不缩放底下画布、光标在 AI 面板或左侧资源管理器上时各滚各的。捏合手势自动继承了全部守卫------这是"复用现有分支"的另一个红利:边界条件不用重写一遍。
缩放走 Camera::zoom_at,锚点数学值得单独讲,因为这是所有"以光标为中心缩放"的通用做法:
rust
pub fn zoom_at(&mut self, factor: f64, screen_focus: Point<Pixels>, canvas_origin: Point<Pixels>) {
let focus_world = self.screen_to_world(screen_focus, canvas_origin);
self.zoom = (self.zoom * factor).clamp(MIN_ZOOM, MAX_ZOOM);
// Re-anchor so focus_world stays under the cursor.
self.x = focus_world.x - (screen_focus.x - canvas_origin.x).to_f64() / self.zoom;
self.y = focus_world.y - (screen_focus.y - canvas_origin.y).to_f64() / self.zoom;
}
三步:① 先算出光标下面那个"世界坐标点"是什么;② 缩放系数乘上去,钳位在 0.1~8.0;③ 反推相机原点,让第①步那个世界点缩放后还压在光标下面。翻译成人话:缩放不是"放大整张纸",而是"按住光标下面那个点不动,把纸撑开"。双指滚动平移、shift+滚轮横滚走别的分支,互不干扰。
5. 补丁的保质期:升级时记得还债
故事有个诚实的结尾。2026 年 10 月升级 gpui 生态到 0.7(gpui-pre 0.3.8,Zed 快照)时,vendor/gpui 整个被删了------commit message 里写得明明白白:"mac 触控板补丁随 crate 改名失效,待需要时在 gpui-pre-platform 重移植"。升级带来了内存 300MB→123MB 的大收益,但捏合补丁这笔债,得在新平台包里重还。
这就是 vendor 补丁的保质期管理:打补丁的那一刻,就要写好"技术债借条"------commit message 写清"随什么失效、到哪重移植",依赖声明旁写清"为什么不能删"。框架升级时 grep 一遍这些借条,决定还还是续。最怕的是补丁悄无声息地躺着,升级后才发现手势又死了,还得重新诊断一遍。
6. 避坑清单
- 手势无响应先定位事件死在哪一层:选择器没注册(系统不发)和转换表没该类型(框架丢弃)是两种死法,修法完全不同。
- 合成事件时修饰键必须补全(这里是
platform = true),否则应用层路由不到缩放分支------翻译要翻全套,不能只翻一半。 - 坐标系原点要对齐:AppKit 左下、GPUI 左上,
locationInWindow().y必须用window_height - y翻转,否则缩放中心上下颠倒。 - 合成系数要和应用层缩放公式对齐(这里是
exp(-dy*0.002)),推导出"手指-画面 1:1"的系数,别拍脑袋。 - vendor 补丁的"为什么"写在
Cargo.toml依赖声明旁,commit message 写清失效条件和重移植位置------升级框架时这是你的寻债地图。
7. 给开发者的建议
- 框架吃掉系统手势时,别急着在应用层加手势识别------先顺着"系统→视图→转换表→应用层"查事件死在哪,90% 死在框架层。
- 优先"事件合成"而非"新增事件类型":把系统事件翻译成框架已认识的事件,复用现有分支和全部边界守卫。
- 系数选择要有数学依据:从应用层公式反推,让"手指动作量 = 画面变化量",手感才对。
- zoom-at-cursor 的锚点数学(先算世界点、再缩放、再反推原点)是通用套路,任何画布/地图/图片查看器都用得上,直接抄。
- 每个 vendor 补丁都是一笔有保质期的技术债:写清借条,升级时主动重评估,别等用户报"手势又死了"。
框架是别人的,手势是用户的。中间那道缝,得自己拿针线缝上------针法就是:诊断到层、翻译事件、对齐系数、写好借条。