柚子/用 Rust 在 macOS 上打造自定义手势:以“拖拽摇晃”为例

Created Fri, 27 Jun 2025 00:00:00 +0000 Modified Fri, 27 Jun 2025 04:50:29 +0000
By Yoyo 3518 Words 16 min Edit

在人机交互领域,手势是连接用户意图与软件功能的桥梁。除了系统预设的点击、双击、拖拽等,我们能否创造出更具表现力的新手势?答案是肯定的。

今天,我们将深入探讨一个具体的技术实践:如何在 macOS 上,使用 Rust 语言捕获并识别一个自定义的组合手势——“拖拽并摇晃鼠标”。这个过程将带领我们探索 macOS 的底层事件系统、与 Objective-C 运行时交互,并最终实现一个有趣的手势识别算法。

这不仅是一次技术挑战,更是一次关于如何扩展原生用户体验的探索。首先,我们来看一下整个事件从被捕获到最终触发自定义回调的完整流程。

graph TD
    A["macOS 系统: <br>产生HID事件<br>(鼠标按下/移动/抬起)"] --> B{"Rust FFI 边界:<br>CGEventTap Callback"}
    B --> C[核心分发逻辑]

    subgraph "事件处理"
        C -- LeftMouseDown --> D[记录初始 changeCount<br>重置状态]
        C -- LeftMouseDragged --> E{"拖拽是否已开始?<br>(changeCount变化)"}
        C -- LeftMouseUp --> F[触发 DragEnd 回调<br>重置状态]
    end

    subgraph "拖拽过程"
        E -- 是 --> G["1. 解析拖拽数据 (DragData)<br>2. 触发 DragStart 回调<br>3. 将坐标送入摇晃检测器"]
        E -- 否 --> H["..."]
        G --> I{检测到摇晃?}
    end

    subgraph "摇晃检测"
        I -- 是 --> J[触发 Shake 回调<br>标记本次摇晃已处理]
        I -- 否 --> K["..."]
    end

第一部分:捕获全局鼠标事件

要识别一个系统级的手势,第一步就是要能“看到”用户的操作。这意味着我们需要一个方法来监听全局的鼠标事件,无论当前焦点在哪一个应用上。在 macOS 中,实现这一目标的标准方法是使用 Core Graphics 框架提供的 Event Taps

Event Tap 是什么?

Event Tap 相当于在系统事件流中安插的一个“探针”。它可以被配置为监听(Listen-Only)或过滤(Filtering)几乎所有类型的 HID(Human Interface Device)事件。我们的目标是监听,所以不会修改或阻止任何事件。

在 Rust 中创建 Event Tap

我们需要通过 Rust 的 FFI(Foreign Function Interface)来调用 C ABI 的 Core Graphics 函数。核心函数是 CGEventTapCreate。首先,我们需要声明它的 Rust FFI 签名:

// 在 Rust 中声明需要用到的 C 函数
#[link(name = "CoreFoundation", kind = "framework")]
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
    fn CGEventTapCreate(
        tap: CGEventTapLocation,
        place: u32, // CGEventTapPlacement
        options: u32, // CGEventTapOptions
        eventsOfInterest: u64, // CGEventMask
        callback: CGEventTapCallBack,
        userInfo: *mut c_void,
    ) -> CFMachPortRef;

    // ... 其他 RunLoop 相关函数
}

// 定义回调函数的类型别名
type CGEventTapCallBack = Option<
    unsafe extern "C" fn(
        proxy: CGEventTapProxy,
        type_: CGEventType,
        event: CGEventRef,
        userInfo: *mut c_void,
    ) -> CGEventRef,
>;

在调用 CGEventTapCreate 时,每个参数都至关重要:

  • tap: 我们选择 kCGHIDEventTap,表示直接在 HID 事件源头监听,这是最低级别的监听点。
  • place: 使用 kCGHeadInsertEventTap,将我们的 Tap 放在现有 Tap 链条的头部。
  • options: 设置为 kCGEventTapOptionListenOnly,明确我们只看不碰。
  • eventsOfInterest: 这是一个位掩码(bitmask),用于精确指定我们关心的事件。为了性能,我们只监听必要的事件:
    let event_mask = (1 << CGEventType::LeftMouseDown as u64)
        | (1 << CGEventType::LeftMouseDragged as u64)
        | (1 << CGEventType::LeftMouseUp as u64);
    
  • callback: 一个 unsafe extern "C" 函数指针,当被监听的事件发生时,系统将调用它。
  • userInfo: 一个 *mut c_void 的“用户数据”指针。这是 FFI 编程中的一个关键技巧,我们可以用它把 Rust 世界的上下文(比如一个闭包或对象指针)传递到 C 回调函数中。
事件循环(RunLoop)

创建了 Event Tap 后,它并不会立即工作。我们需要将它添加到一个 CFRunLoop(Core Foundation RunLoop)中并启动这个循环。CFRunLoop 是 macOS/iOS 应用中事件处理的核心机制。调用 CFRunLoopRun() 会阻塞当前线程,专门用于等待和分派事件。因此,在真实的 GUI 应用中,整个监听逻辑必须在一个独立的后台线程中运行,以防阻塞主线程。

第二部分:解码拖拽操作的秘密

光监听到鼠标拖动是不够的,我们还需要知道用户何时真正“拿起”了一个可拖拽的物品。直接在 LeftMouseDown 后就开始分析鼠标轨迹会产生大量误判。

揭秘 NSPasteboardchangeCount

macOS 为拖拽操作提供了一个特殊的剪贴板,名为 NSPasteboardNameDrag。它独立于我们日常 Cmd+C / Cmd+V 使用的通用剪贴板。

这个拖拽剪贴板有一个非常有用的属性:changeCount。这是一个整数,每当剪贴板上的内容发生变化时(例如,用户从 Finder 拖起一个文件),这个数字就会自增。这为我们提供了一个完美的判断依据。

我们的策略是:

  1. LeftMouseDown 事件发生时,立即获取并存储当时的 changeCount,记为 initial_change_count
  2. 在随后的 LeftMouseDragged 事件中,反复获取当前的 changeCount
  3. 如果 current_change_count 不再等于 initial_change_count,我们就百分之百确定,一个拖拽操作已经正式开始。

在 Rust 中,我们需要使用 objc crate 来与 Objective-C 运行时交互,以获取这个属性:

use objc::{class, msg_send, sel, sel_impl};
use objc::runtime::Object;

// 获取拖拽剪贴板的 changeCount
pub fn get_drag_pasteboard_change_count() -> i64 {
    unsafe {
        // autoreleasepool 确保 Objective-C 对象被正确释放
        autoreleasepool(|| {
            // let pasteboard = NSPasteboard.pasteboardWithName(NSPasteboardNameDrag);
            let cls = class!(NSPasteboard);
            let pasteboard: *mut Object = msg_send![cls, pasteboardWithName: NSPasteboardNameDrag];

            if pasteboard.is_null() { return 0; }

            // let count = pasteboard.changeCount;
            let count: i64 = msg_send![pasteboard, changeCount];
            count
        })
    }
}
读取拖拽内容

一旦确认拖拽开始,下一个问题是:用户拖的是什么? 这同样需要查询拖拽剪贴板。剪贴板上的数据以不同的类型(UTI,Uniform Type Identifiers)存储。我们需要按优先级顺序进行查询:

  1. 文件 (NSFilenamesPboardType): 检查是否有文件。如果有,内容会是一个包含多条文件路径的列表。
  2. 富文本 (public.html): 检查是否为 HTML 内容。
  3. 纯文本 (public.utf8-plain-text): 最后检查是否为纯文本。

一个有趣的实践技巧是处理从浏览器拖拽的图片。当从网页上拖动一张图片时,剪贴板上通常会同时存在 public.html(内容是 <img> 标签)和 public.utf8-plain-text(内容是图片的 URL)。通过这个组合特征,我们可以精准地识别出这是一个远程图片拖拽,而非普通的富文本。

将这些逻辑封装成一个 Rust enum,可以清晰地表达拖拽物的数据结构:

#[derive(Debug, Clone)]
pub enum DragData {
    LocalFile(Vec<std::path::PathBuf>),
    RemoteImage(Vec<String>),
    PlainText(String),
    RichText { html: String, plain_text_fallback: String },
}

第三部分:实现“摇晃”手势识别算法

这是最核心的部分。如何用算法来定义“摇晃”?一个直观的定义是:在短时间内的、快速的、连续的方向变化

算法设计
  1. 维护一个有时限的历史轨迹: 我们使用一个双端队列 VecDeque<MousePosition> 来存储鼠标轨迹,其中 MousePosition 包含坐标和时间戳 Instant。这个队列只保留最近 500 毫秒的记录,确保我们只分析当前的用户动作。

  2. 检测方向逆转: 当新的鼠标点加入时,我们查看最近的三个点:p1 (更早)、p2 (中间)、p3 (最新)。

    • p1p2 的向量是 (p2.x - p1.x, p2.y - p1.y)
    • p2p3 的向量是 (p3.x - p2.x, p3.y - p2.y)
    • 如果 (p2.x - p1.x)(p3.x - p2.x) 的符号相反,说明 X 方向发生了逆转。Y 方向同理。只要有一个轴发生逆转,就算一次方向变化。
  3. 对快速变化进行计数: 我们只关心“快速”的晃动。因此,我们引入一个 direction_changes 计数器,并记录上次方向变化的时间 last_direction_change

    • 如果当前方向变化与上一次变化的时间间隔小于一个阈值(例如 200 毫秒),则 direction_changes += 1
    • 如果间隔过长,说明用户只是慢速地改变方向,我们将 direction_changes 重置为 1。
  4. 触发摇晃状态: 当 direction_changes 的值超过一个阈值(例如 4 次),我们就认为“摇晃”手势达成了,并将一个布尔状态 is_shaking 设为 true

状态管理

为了防止一次摇晃动作触发多次事件,算法中还需要一个状态 shake_detected_in_current_drag。一旦摇晃被检测到并处理后,就立即设置该状态,直到整个拖拽动作结束(即 LeftMouseUp 事件发生)时才重置。

第四部分:用状态机整合一切

最后,我们需要一个中心状态机来管理整个流程。这个状态机可以用一个简单的 struct 来实现,它驱动着上述所有模块的协作。下面的状态图清晰地展示了它的工作模式:

stateDiagram-v2
    [*] --> Idle: 应用启动

    Idle --> MouseDown: on LeftMouseDown

    MouseDown --> Idle: on LeftMouseUp <br/> (无拖拽发生)
    MouseDown --> Dragging: on LeftMouseDragged <br/> and changeCount changed

    Dragging --> Idle: on LeftMouseUp

    state Dragging {
      direction LR
      [*] --> Active
      Active --> ShakeDetected : 摇晃算法触发
      ShakeDetected --> Active : 继续拖拽
    }

它的生命周期如下:

  1. 空闲 (Idle): 等待 LeftMouseDown
  2. 按下 (MouseDown): 收到 LeftMouseDown,记录初始 changeCount,重置摇晃检测器。如果在此状态收到 LeftMouseUp,说明只是普通点击,直接返回空闲状态。
  3. 拖拽中 (Dragging): 在 LeftMouseDragged 事件中,一旦检测到 changeCount 变化,便进入此状态。在此状态下:
    a. 触发一个“拖拽开始”的回调,并将解析出的 DragData 传出。
    b. 持续将鼠标坐标送入摇晃检测算法。
    c. 如果算法检测到摇晃,则进入临时的 ShakeDetected 子状态,触发“摇晃”回调,然后可以返回继续拖拽。
  4. 结束 (End): 无论从 Dragging 还是 MouseDown 状态,只要收到 LeftMouseUp,就触发“拖拽结束”回调,并重置整个状态机,回到空闲状态。

结语

通过这个实践,我们完成了一次有趣的“穿越”:从最底层的系统事件监听,到与 Objective-C 运行时的交互,再到上层的纯 Rust 算法实现。我们看到,Rust 凭借其强大的 FFI 能力、内存安全保障和高性能特性,非常适合编写这类对性能和稳定性要求都很高的系统级工具。

本文所探讨的技术和方法,不仅可以用于实现“拖拽摇晃”,更可以作为你创造其他任何自定义手势的起点。希望这次深入的分享,能为你打开一扇通往更丰富人机交互设计的大门。

完整开源代码:Github