diff --git a/Mos/ButtonCore/ButtonCore.swift b/Mos/ButtonCore/ButtonCore.swift index 3dfb87e2..7b07dedd 100644 --- a/Mos/ButtonCore/ButtonCore.swift +++ b/Mos/ButtonCore/ButtonCore.swift @@ -20,6 +20,7 @@ class ButtonCore { // 拦截层 var dispatchInterceptor: Interceptor? var primaryObservationInterceptor: Interceptor? + var tiltScrollInterceptor: Interceptor? // 组合的按钮事件掩码 let leftDown = CGEventMask(1 << CGEventType.leftMouseDown.rawValue) @@ -31,6 +32,7 @@ class ButtonCore { let flagsChanged = CGEventMask(1 << CGEventType.flagsChanged.rawValue) let otherUp = CGEventMask(1 << CGEventType.otherMouseUp.rawValue) let keyUp = CGEventMask(1 << CGEventType.keyUp.rawValue) + let tiltScrollEventMask = CGEventMask(1 << CGEventType.scrollWheel.rawValue) var dispatchEventMask: CGEventMask { return otherDown | otherUp | keyDown | keyUp } @@ -80,6 +82,27 @@ class ButtonCore { } return Unmanaged.passUnretained(event) } + + let tiltScrollCallBack: CGEventTapCallBack = { (proxy, type, event, refcon) in + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + TiltWheelHandler.shared.clearState() + return Unmanaged.passUnretained(event) + } + guard type == .scrollWheel, !ScrollEvent.isTrackpad(with: event) else { + return Unmanaged.passUnretained(event) + } + // macOS 会将 Shift+上下滚动转换为水平滚动, 需排除以避免误触发. + // 同时延长冷却窗口, 防止 Shift 释放瞬间的残留事件穿透. + if TiltWheelHandler.isModifierDrivenHorizontalScroll(event) { + TiltWheelHandler.shared.notifyModifierActive() + return Unmanaged.passUnretained(event) + } + guard let code = TiltWheelHandler.tiltCode(for: event) else { + return Unmanaged.passUnretained(event) + } + let shouldConsume = TiltWheelHandler.shared.handle(code: code) + return shouldConsume ? nil : Unmanaged.passUnretained(event) + } // MARK: - 启用和禁用 @@ -87,6 +110,17 @@ class ButtonCore { func enable() { if !isActive { do { + // 倾斜滚轮拦截器: headInsert 确保在 ScrollCore 的 tailAppend 之前处理, 可消费事件 + tiltScrollInterceptor = try Interceptor( + event: tiltScrollEventMask, + handleBy: tiltScrollCallBack, + listenOn: .cgAnnotatedSessionEventTap, + placeAt: .headInsertEventTap, + for: .defaultTap + ) + tiltScrollInterceptor?.onRestart = { + TiltWheelHandler.shared.clearState() + } dispatchInterceptor = try Interceptor( event: dispatchEventMask, handleBy: buttonEventCallBack, @@ -106,24 +140,29 @@ class ButtonCore { ) isActive = true } catch { + tiltScrollInterceptor?.stop() dispatchInterceptor?.stop() primaryObservationInterceptor?.stop() + tiltScrollInterceptor = nil dispatchInterceptor = nil primaryObservationInterceptor = nil NSLog("ButtonCore: Failed to create interceptor: \(error)") } } } - + // 禁用按钮监控 func disable() { if isActive { NSLog("ButtonCore disabled") + tiltScrollInterceptor?.stop() dispatchInterceptor?.stop() primaryObservationInterceptor?.stop() + tiltScrollInterceptor = nil dispatchInterceptor = nil primaryObservationInterceptor = nil InputProcessor.shared.clearActiveBindings() + TiltWheelHandler.shared.clearState() isActive = false } } diff --git a/Mos/ButtonCore/TiltWheelHandler.swift b/Mos/ButtonCore/TiltWheelHandler.swift new file mode 100644 index 00000000..cf10b840 --- /dev/null +++ b/Mos/ButtonCore/TiltWheelHandler.swift @@ -0,0 +1,167 @@ +// +// TiltWheelHandler.swift +// Mos +// 倾斜滚轮虚拟按键处理器 +// Created by Claude on 2026/4/23. +// Copyright © 2026 Caldis. All rights reserved. +// + +import Cocoa + +/// 将连续的水平滚轮事件流转换为离散的 down/up 生命周期,送入 InputProcessor 管线。 +/// 支持类似键盘按键重复的行为:倾斜开始时立即触发 down,持续倾斜时按固定间隔重复触发 down, +/// 停止倾斜后触发 up。 +/// +/// 线程模型: +/// handle(xDelta:) 在 CGEventTap 回调线程调用。 +/// synthesize / 所有计时器操作全部在主线程执行,activeCode 等状态仅在主线程访问。 +class TiltWheelHandler { + static let shared = TiltWheelHandler() + private init() {} + + // 倾斜停止判定:最后一次事件后超过此时间则认为倾斜结束 + // 设为 60ms:确保连打间隔(通常 80~150ms 静默)能被准确识别为独立手势 + // 低于连续事件间隔(20~50ms),因此持续倾斜时不会误触发停止判定 + private static let stopInterval: TimeInterval = 0.060 + // 键位重复:首次重复前的初始延迟(仿 macOS 键盘重复行为) + private static let repeatInitialDelay: TimeInterval = 0.400 + // 键位重复:后续重复间隔 + private static let repeatInterval: TimeInterval = 0.200 + // Shift/切换键冷却:释放后继续抑制的时间窗口,防止过渡期残留事件误触发 + private static let shiftCooldownInterval: TimeInterval = 0.150 + + // MARK: - 主线程状态 + + private var activeCode: UInt16? + /// 停止检测计时器:最后一次事件后 stopInterval 无事件则触发 up + private var stopTimer: Timer? + /// 重复计时器:倾斜持续期间按 repeatInterval 重复触发 down + private var repeatTimer: Timer? + + // MARK: - Shift/切换键抑制(从回调线程设置,Date 为值类型,赋值无并发风险) + + private var shiftSuppressedUntil: Date = .distantPast + + /// 检测到 Shift 或切换键活跃的水平滚动时调用,延长抑制窗口 + func notifyModifierActive() { + shiftSuppressedUntil = Date().addingTimeInterval(TiltWheelHandler.shiftCooldownInterval) + } + + private var isModifierSuppressed: Bool { + return Date() < shiftSuppressedUntil + } + + // MARK: - 事件过滤辅助 (ButtonCore 拦截回调与 KeyRecorder 录制共用) + + /// 判断是否为 Shift 修饰键或 toggleScroll 驱动的水平滚动 (应排除以避免误触发) + static func isModifierDrivenHorizontalScroll(_ event: CGEvent) -> Bool { + return event.flags.contains(.maskShift) || ScrollCore.shared.toggleScroll + } + + /// 从滚轮事件中提取倾斜方向对应的虚拟键码 + /// 要求纯水平滚动 (xDelta != 0 且 yDelta == 0), 否则返回 nil + static func tiltCode(for event: CGEvent) -> UInt16? { + let xDelta = event.getDoubleValueField(.scrollWheelEventPointDeltaAxis2) + let yDelta = event.getDoubleValueField(.scrollWheelEventPointDeltaAxis1) + guard xDelta != 0.0, yDelta == 0.0 else { return nil } + return xDelta > 0 ? KeyCode.tiltRight : KeyCode.tiltLeft + } + + // MARK: - 公开接口 + + /// 由 ButtonCore 的 scrollWheel 拦截器回调调用(回调线程)。 + /// 返回 true 表示该方向有绑定,调用方应消费(返回 nil)该事件。 + func handle(code: UInt16) -> Bool { + guard !isModifierSuppressed else { return false } + let hasBinding = !ButtonUtils.shared.getButtonBindings(for: .mouse, code: code).isEmpty + DispatchQueue.main.async { [weak self] in + self?.synthesize(code: code) + } + return hasBinding + } + + /// 清理所有进行中的状态。ButtonCore disable 时调用,防止状态残留。 + func clearState() { + shiftSuppressedUntil = .distantPast + if let code = activeCode { + endTilt(code) + } + } + + // MARK: - 主线程私有逻辑 + + private func synthesize(code: UInt16) { + guard let current = activeCode else { + beginTilt(code) + return + } + if current == code { + // 同方向持续倾斜:重置停止计时器,保持重复计时器运行 + resetStopTimer(for: code) + } else { + // 方向切换:结束旧手势,开始新手势 + endTilt(current) + beginTilt(code) + } + } + + /// 开始新的倾斜手势:立即触发 down,启动重复和停止计时器 + private func beginTilt(_ code: UInt16) { + activeCode = code + fireInputEvent(code: code, phase: .down) + startRepeatTimer(for: code) + resetStopTimer(for: code) + } + + /// 结束倾斜手势:取消计时器,触发 up + private func endTilt(_ code: UInt16) { + stopTimer?.invalidate() + stopTimer = nil + repeatTimer?.invalidate() + repeatTimer = nil + activeCode = nil + fireInputEvent(code: code, phase: .up) + } + + /// 重置停止检测计时器。每次收到同方向事件时调用,延迟判定倾斜结束。 + private func resetStopTimer(for code: UInt16) { + stopTimer?.invalidate() + stopTimer = Timer.scheduledTimer(withTimeInterval: TiltWheelHandler.stopInterval, repeats: false) { [weak self] _ in + guard let self = self, self.activeCode == code else { return } + self.endTilt(code) + } + } + + /// 启动键位重复计时器:初始延迟后开始,之后按固定间隔触发 down。 + private func startRepeatTimer(for code: UInt16) { + repeatTimer?.invalidate() + // 初始延迟:等待 repeatInitialDelay 后开始重复 + repeatTimer = Timer.scheduledTimer(withTimeInterval: TiltWheelHandler.repeatInitialDelay, repeats: false) { [weak self] _ in + guard let self = self, self.activeCode == code else { return } + self.fireInputEvent(code: code, phase: .down) + // 切换为固定间隔重复 + self.repeatTimer = Timer.scheduledTimer(withTimeInterval: TiltWheelHandler.repeatInterval, repeats: true) { [weak self] _ in + guard let self = self, self.activeCode == code else { + self?.repeatTimer?.invalidate() + return + } + self.fireInputEvent(code: code, phase: .down) + } + } + } + + // MARK: - 事件发送 + + private func fireInputEvent(code: UInt16, phase: InputPhase) { + let modifiers = CGEventSource.flagsState(.combinedSessionState) + let event = InputEvent( + type: .mouse, + code: code, + modifiers: modifiers, + phase: phase, + source: .hidPP, + device: nil + ) + _ = InputProcessor.shared.process(event) + } +} diff --git a/Mos/Keys/KeyCode.swift b/Mos/Keys/KeyCode.swift index 677b30aa..23afab3f 100644 --- a/Mos/Keys/KeyCode.swift +++ b/Mos/Keys/KeyCode.swift @@ -153,6 +153,10 @@ struct KeyCode { 179: "Fn (179)", // 179 可以通过双击 FN 触发 ] + /// 倾斜滚轮虚拟按键码 + static let tiltLeft: UInt16 = 21 + static let tiltRight: UInt16 = 22 + /// 鼠标字符映射 static let mouseMap: [UInt16: String] = [ // 主要 @@ -162,6 +166,8 @@ struct KeyCode { 9: "🖱9", 10: "🖱10", 11: "🖱11", 12: "🖱12", 13: "🖱13", 14: "🖱14", 15: "🖱15", 16: "🖱16", 17: "🖱17", 18: "🖱18", 19: "🖱19", 20: "🖱20", + // 倾斜滚轮 + 21: "🖱←", 22: "🖱→", ] static let mouseMainKeys: [UInt16] = [0,1] // Only protect left/right clicks, allow middle button without modifiers } diff --git a/Mos/Keys/KeyRecorder.swift b/Mos/Keys/KeyRecorder.swift index c5db2372..4f371d54 100644 --- a/Mos/Keys/KeyRecorder.swift +++ b/Mos/Keys/KeyRecorder.swift @@ -104,7 +104,8 @@ class KeyRecorder: NSObject { let otherDown = CGEventMask(1 << CGEventType.otherMouseDown.rawValue) let keyDown = CGEventMask(1 << CGEventType.keyDown.rawValue) let flagsChanged = CGEventMask(1 << CGEventType.flagsChanged.rawValue) - return leftDown | rightDown | otherDown | keyDown | flagsChanged + let scrollWheel = CGEventMask(1 << CGEventType.scrollWheel.rawValue) + return leftDown | rightDown | otherDown | keyDown | flagsChanged | scrollWheel } // MARK: - Recording Manager @@ -171,6 +172,27 @@ class KeyRecorder: NSObject { object: recordedEvent ) } + case .scrollWheel: + // 倾斜滚轮: 检测纯水平倾斜并作为虚拟鼠标键录制 + // 排除触控板、macOS 的 Shift+上下滚动转换以及 toggleScroll 切换引发的水平滚动 + if !ScrollEvent.isTrackpad(with: recordedEvent), + !TiltWheelHandler.isModifierDrivenHorizontalScroll(recordedEvent), + let tiltCode = TiltWheelHandler.tiltCode(for: recordedEvent) { + let tiltEvent = InputEvent( + type: .mouse, + code: tiltCode, + modifiers: recordedEvent.flags, + phase: .down, + source: .hidPP, + device: nil + ) + DispatchQueue.main.async { + NotificationCenter.default.post( + name: KeyRecorder.FINISH_NOTI_NAME, + object: tiltEvent + ) + } + } case .leftMouseDown, .rightMouseDown, .otherMouseDown: // 鼠标按键 DispatchQueue.main.async {