Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion Mos/ButtonCore/ButtonCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class ButtonCore {
// 拦截层
var dispatchInterceptor: Interceptor?
var primaryObservationInterceptor: Interceptor?
var tiltScrollInterceptor: Interceptor?

// 组合的按钮事件掩码
let leftDown = CGEventMask(1 << CGEventType.leftMouseDown.rawValue)
Expand All @@ -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
}
Expand Down Expand Up @@ -80,13 +82,45 @@ 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: - 启用和禁用

// 启用按钮监控
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,
Expand All @@ -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
}
}
Expand Down
167 changes: 167 additions & 0 deletions Mos/ButtonCore/TiltWheelHandler.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 6 additions & 0 deletions Mos/Keys/KeyCode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
// 主要
Expand All @@ -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
}
24 changes: 23 additions & 1 deletion Mos/Keys/KeyRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down