diff --git a/Mos.xcodeproj/project.pbxproj b/Mos.xcodeproj/project.pbxproj index 086611af..d0735f68 100644 --- a/Mos.xcodeproj/project.pbxproj +++ b/Mos.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ A1B2C3D4E5F6A7B8C9D0E1F2 /* MosTests/ButtonBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F3 /* MosTests/ButtonBindingTests.swift */; }; B2C3D4E5F6A7B8C9D0E1F200 /* MosTests/ButtonUtilsCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F6A7B8C9D0E1F201 /* MosTests/ButtonUtilsCacheTests.swift */; }; B3D4E5F6A7B8C9D0E1F40100 /* MosTests/MouseInteractionSessionControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D4E5F6A7B8C9D0E1F40101 /* MosTests/MouseInteractionSessionControllerTests.swift */; }; + D4E5F6A7B8C9D0E1F50300 /* MosTests/GestureProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F6A7B8C9D0E1F50301 /* MosTests/GestureProcessorTests.swift */; }; BF0CC4D63CC0E5FE8FCF6E01 /* MosTests/ScrollFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31AFD832EF49533E8168BAA0 /* MosTests/ScrollFilterTests.swift */; }; C3D4E5F6A7B8C9D0E1F30200 /* MosTests/InputProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D4E5F6A7B8C9D0E1F30201 /* MosTests/InputProcessorTests.swift */; }; FE05F3803FB8885AAC70ADC5 /* MosTests/ScrollCoreHotkeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6CF5441214A14BB8FFEDA5 /* MosTests/ScrollCoreHotkeyTests.swift */; }; @@ -46,6 +47,7 @@ A1B2C3D4E5F6A7B8C9D0E1F3 /* MosTests/ButtonBindingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MosTests/ButtonBindingTests.swift; sourceTree = SOURCE_ROOT; }; B2C3D4E5F6A7B8C9D0E1F201 /* MosTests/ButtonUtilsCacheTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MosTests/ButtonUtilsCacheTests.swift; sourceTree = SOURCE_ROOT; }; B3D4E5F6A7B8C9D0E1F40101 /* MosTests/MouseInteractionSessionControllerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MosTests/MouseInteractionSessionControllerTests.swift; sourceTree = SOURCE_ROOT; }; + D4E5F6A7B8C9D0E1F50301 /* MosTests/GestureProcessorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MosTests/GestureProcessorTests.swift; sourceTree = SOURCE_ROOT; }; C3D4E5F6A7B8C9D0E1F30201 /* MosTests/InputProcessorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MosTests/InputProcessorTests.swift; sourceTree = SOURCE_ROOT; }; D3A7FDB8A6001907C9F156D6 /* MosTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MosTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E1B8B50E951D72C6195DBA3A /* MosTests/ScrollEventTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MosTests/ScrollEventTests.swift; sourceTree = SOURCE_ROOT; }; @@ -95,6 +97,7 @@ B2C3D4E5F6A7B8C9D0E1F201 /* MosTests/ButtonUtilsCacheTests.swift */, C3D4E5F6A7B8C9D0E1F30201 /* MosTests/InputProcessorTests.swift */, B3D4E5F6A7B8C9D0E1F40101 /* MosTests/MouseInteractionSessionControllerTests.swift */, + D4E5F6A7B8C9D0E1F50301 /* MosTests/GestureProcessorTests.swift */, 9D4A7F821E5626C63C45D698 /* MosTests/InterpolatorTests.swift */, 2D6CF5441214A14BB8FFEDA5 /* MosTests/ScrollCoreHotkeyTests.swift */, 25DD10CB06A5D177E3BD0845 /* MosTests/ScrollDispatchContextTests.swift */, @@ -267,6 +270,7 @@ B2C3D4E5F6A7B8C9D0E1F200 /* MosTests/ButtonUtilsCacheTests.swift in Sources */, C3D4E5F6A7B8C9D0E1F30200 /* MosTests/InputProcessorTests.swift in Sources */, B3D4E5F6A7B8C9D0E1F40100 /* MosTests/MouseInteractionSessionControllerTests.swift in Sources */, + D4E5F6A7B8C9D0E1F50300 /* MosTests/GestureProcessorTests.swift in Sources */, FE05F3803FB8885AAC70ADC5 /* MosTests/ScrollCoreHotkeyTests.swift in Sources */, 0E590C87FC41F66FF48D518F /* MosTests/ScrollDispatchContextTests.swift in Sources */, 296126EA068FF77D423ACAA9 /* MosTests/ScrollEventTests.swift in Sources */, diff --git a/Mos/ButtonCore/ButtonCore.swift b/Mos/ButtonCore/ButtonCore.swift index fe9b3c85..018c8500 100644 --- a/Mos/ButtonCore/ButtonCore.swift +++ b/Mos/ButtonCore/ButtonCore.swift @@ -48,6 +48,13 @@ class ButtonCore { // 使用原始 flags 匹配绑定 (不注入虚拟修饰键, 保证匹配准确) let mosEvent = InputEvent(fromCGEvent: event) + + // 手势处理 (优先于 ButtonBinding 处理) + let gestureResult = GestureProcessor.shared.handleButtonEvent(mosEvent, cgEvent: event) + if gestureResult == .consumed { + return nil + } + let result = InputProcessor.shared.process(mosEvent) switch result { case .consumed: @@ -102,6 +109,7 @@ class ButtonCore { eventInterceptor?.stop() eventInterceptor = nil InputProcessor.shared.clearActiveBindings() + GestureProcessor.shared.clearState() isActive = false } } diff --git a/Mos/InputEvent/GestureBinding.swift b/Mos/InputEvent/GestureBinding.swift new file mode 100644 index 00000000..2e67526d --- /dev/null +++ b/Mos/InputEvent/GestureBinding.swift @@ -0,0 +1,224 @@ +// +// GestureBinding.swift +// Mos +// 鼠标手势绑定数据结构 +// Created by Claude on 2026/4/15. +// Copyright © 2026 Caldis. All rights reserved. +// + +import Cocoa + +// MARK: - GestureDirection + +/// 手势方向 +enum GestureDirection: String, Codable, CaseIterable { + case up = "up" + case down = "down" + case left = "left" + case right = "right" + + /// 方向箭头符号 (用于 UI 显示) + var arrowSymbol: String { + switch self { + case .up: return "↑" + case .down: return "↓" + case .left: return "←" + case .right: return "→" + } + } + + /// 本地化显示名称 + var localizedName: String { + return NSLocalizedString(rawValue, comment: "") + } +} + +// MARK: - GestureBinding + +/// 手势绑定 - 将录制的触发按键与动作关联 +/// Movement (鼠标移动): 4 方向 (↑↓←→), 阈值默认 30px +/// Scroll (滚轮滚动): 2 方向 (↑↓), 阈值默认 3 tick +/// 两种输入模式相互独立, 可同时配置 +struct GestureBinding: Codable, Equatable { + + // MARK: - 持久化字段 + + let id: UUID + let triggerEvent: RecordedEvent + let createdAt: Date + + // --- Movement 动作 (鼠标移动方向, 4 方向) --- + var upAction: String? + var downAction: String? + var leftAction: String? + var rightAction: String? + /// 触发方向识别所需最小移动像素 + var threshold: Double + + // --- Scroll 动作 (滚轮方向, 仅 ↑↓) --- + var scrollUpAction: String? + var scrollDownAction: String? + /// 触发方向识别所需最小滚轮 tick 数 + var scrollThreshold: Double + + var isEnabled: Bool + + // MARK: - 初始化 + + init( + id: UUID = UUID(), + triggerEvent: RecordedEvent, + upAction: String? = nil, + downAction: String? = nil, + leftAction: String? = nil, + rightAction: String? = nil, + threshold: Double = 30.0, + scrollUpAction: String? = nil, + scrollDownAction: String? = nil, + scrollThreshold: Double = 3.0, + isEnabled: Bool = true, + createdAt: Date = Date() + ) { + self.id = id + self.triggerEvent = triggerEvent + self.upAction = upAction + self.downAction = downAction + self.leftAction = leftAction + self.rightAction = rightAction + self.threshold = threshold + self.scrollUpAction = scrollUpAction + self.scrollDownAction = scrollDownAction + self.scrollThreshold = scrollThreshold + self.isEnabled = isEnabled + self.createdAt = createdAt + } + + // MARK: - Codable (backward-compatible) + + enum CodingKeys: String, CodingKey { + case id, triggerEvent, createdAt + case upAction, downAction, leftAction, rightAction, threshold + case scrollUpAction, scrollDownAction, scrollThreshold + case isEnabled + // Legacy key — present in old data, decoded and discarded + case inputMode + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(triggerEvent, forKey: .triggerEvent) + try c.encode(createdAt, forKey: .createdAt) + try c.encodeIfPresent(upAction, forKey: .upAction) + try c.encodeIfPresent(downAction, forKey: .downAction) + try c.encodeIfPresent(leftAction, forKey: .leftAction) + try c.encodeIfPresent(rightAction, forKey: .rightAction) + try c.encode(threshold, forKey: .threshold) + try c.encodeIfPresent(scrollUpAction, forKey: .scrollUpAction) + try c.encodeIfPresent(scrollDownAction, forKey: .scrollDownAction) + try c.encode(scrollThreshold, forKey: .scrollThreshold) + try c.encode(isEnabled, forKey: .isEnabled) + // inputMode intentionally NOT encoded (legacy read-only key) + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(UUID.self, forKey: .id) + triggerEvent = try c.decode(RecordedEvent.self, forKey: .triggerEvent) + createdAt = try c.decode(Date.self, forKey: .createdAt) + + upAction = try c.decodeIfPresent(String.self, forKey: .upAction) + downAction = try c.decodeIfPresent(String.self, forKey: .downAction) + leftAction = try c.decodeIfPresent(String.self, forKey: .leftAction) + rightAction = try c.decodeIfPresent(String.self, forKey: .rightAction) + + // Legacy: threshold was the single threshold field (used for both modes). + // Map it to movement threshold; default 30.0 if absent. + threshold = (try? c.decodeIfPresent(Double.self, forKey: .threshold)) ?? 30.0 + + scrollUpAction = try c.decodeIfPresent(String.self, forKey: .scrollUpAction) + scrollDownAction = try c.decodeIfPresent(String.self, forKey: .scrollDownAction) + scrollThreshold = (try? c.decodeIfPresent(Double.self, forKey: .scrollThreshold)) ?? 3.0 + + isEnabled = try c.decode(Bool.self, forKey: .isEnabled) + + // inputMode key is silently ignored (no longer used) + } + + // MARK: - 方向动作访问 (Movement) + + /// 获取指定 Movement 方向的动作名称 + func action(for direction: GestureDirection) -> String? { + switch direction { + case .up: return upAction + case .down: return downAction + case .left: return leftAction + case .right: return rightAction + } + } + + /// 设置指定 Movement 方向的动作 (返回更新后的副本) + func withAction(_ action: String?, for direction: GestureDirection) -> GestureBinding { + var copy = self + switch direction { + case .up: copy.upAction = action + case .down: copy.downAction = action + case .left: copy.leftAction = action + case .right: copy.rightAction = action + } + return copy + } + + // MARK: - 方向动作访问 (Scroll) + + /// 获取指定 Scroll 方向的动作名称 (仅 .up / .down 有效) + func scrollAction(for direction: GestureDirection) -> String? { + switch direction { + case .up: return scrollUpAction + case .down: return scrollDownAction + default: return nil + } + } + + /// 设置指定 Scroll 方向的动作 (返回更新后的副本) + func withScrollAction(_ action: String?, for direction: GestureDirection) -> GestureBinding { + var copy = self + switch direction { + case .up: copy.scrollUpAction = action + case .down: copy.scrollDownAction = action + default: break + } + return copy + } + + // MARK: - 能力查询 + + var hasAnyMovementAction: Bool { + return upAction != nil || downAction != nil || leftAction != nil || rightAction != nil + } + + var hasAnyScrollAction: Bool { + return scrollUpAction != nil || scrollDownAction != nil + } + + var hasAnyAction: Bool { + return hasAnyMovementAction || hasAnyScrollAction + } + + // MARK: - Equatable + + static func == (lhs: GestureBinding, rhs: GestureBinding) -> Bool { + return lhs.id == rhs.id && + lhs.triggerEvent == rhs.triggerEvent && + lhs.upAction == rhs.upAction && + lhs.downAction == rhs.downAction && + lhs.leftAction == rhs.leftAction && + lhs.rightAction == rhs.rightAction && + lhs.threshold == rhs.threshold && + lhs.scrollUpAction == rhs.scrollUpAction && + lhs.scrollDownAction == rhs.scrollDownAction && + lhs.scrollThreshold == rhs.scrollThreshold && + lhs.isEnabled == rhs.isEnabled && + lhs.createdAt == rhs.createdAt + } +} diff --git a/Mos/InputEvent/GestureProcessor.swift b/Mos/InputEvent/GestureProcessor.swift new file mode 100644 index 00000000..31ce2fff --- /dev/null +++ b/Mos/InputEvent/GestureProcessor.swift @@ -0,0 +1,363 @@ +// +// GestureProcessor.swift +// Mos +// 鼠标手势处理器 - 状态机实现 +// 按下触发按键 + 移动鼠标 → 识别方向 → 执行绑定动作 +// Created by Claude on 2026/4/15. +// Copyright © 2026 Caldis. All rights reserved. +// + +import Cocoa + +// MARK: - GestureProcessor + +class GestureProcessor { + + static let shared = GestureProcessor() + init() { + NSLog("Module initialized: GestureProcessor") + // 注册运动事件回调到 MouseInteractionSessionController + MouseInteractionSessionController.shared.gestureMotionHandler = { [weak self] event in + self?.handleMotionEvent(event) + } + } + + // MARK: - State Machine + + private enum State { + case idle + case pending(binding: GestureBinding, savedCGEventType: CGEventType, savedButtonCode: UInt16, cursorPosition: CGPoint, accumulatedDX: Double, accumulatedDY: Double) + case active(binding: GestureBinding) + } + + private var state: State = .idle + + /// 独立的滚轮累积值 — 与 motion 累积 (state 中的 DX/DY) 完全分离 + /// 进入 pending 时重置, 离开 pending (无论是激活还是取消) 时重置 + private var pendingScrollDY: Double = 0 + + // MARK: - Binding Cache + + /// 缓存的手势绑定列表 (按触发优先级排序: modifier 数量多的优先) + private var cachedBindings: [GestureBinding] = [] + private var isDirty = true + + /// 使缓存失效 (手势绑定更改后调用) + func invalidateCache() { + isDirty = true + } + + private func refreshCacheIfNeeded() { + guard isDirty else { return } + cachedBindings = Options.shared.gestures.binding + .filter { $0.isEnabled } + .sorted { lhs, rhs in + // modifier 数量多的排在前面 (更精确的匹配优先) + let lhsMods = lhs.triggerEvent.modifiers + let rhsMods = rhs.triggerEvent.modifiers + return lhsMods.nonzeroBitCount > rhsMods.nonzeroBitCount + } + isDirty = false + } + + // MARK: - Button Event Handling + + /// 处理按键/鼠标按下/抬起事件 (在 ButtonCore 中 InputProcessor 之前调用) + /// - Returns: .consumed 表示事件已被手势系统处理, .passthrough 表示未匹配 + func handleButtonEvent(_ event: InputEvent, cgEvent: CGEvent) -> InputResult { + switch state { + case .idle: + guard event.phase == .down else { return .passthrough } + return handleDownInIdle(event: event, cgEvent: cgEvent) + + case .pending(let binding, let savedType, let savedCode, let cursorPos, let dx, let dy): + if event.phase == .down { + // 另一个按键按下时取消手势, 回放原始点击 + replayOriginalClick(eventType: savedType, buttonCode: savedCode, position: cursorPos) + stopGestureTracking() + pendingScrollDY = 0 + state = .idle + return .passthrough + } else { + // Up 事件: 检查是否为触发按键的松开 + if event.type == binding.triggerEvent.type && event.code == binding.triggerEvent.code { + // 阈值未达到 → 回放原始点击 + _ = dx; _ = dy + replayOriginalClick(eventType: savedType, buttonCode: savedCode, position: cursorPos) + stopGestureTracking() + pendingScrollDY = 0 + state = .idle + return .consumed + } + return .passthrough + } + + case .active(let binding): + // 手势已激活: 等待触发按键松开 + if event.phase == .up && + event.type == binding.triggerEvent.type && + event.code == binding.triggerEvent.code { + stopGestureTracking() + state = .idle + return .consumed + } + // 其他按键事件放行 + return event.phase == .down ? .passthrough : .passthrough + } + } + + // MARK: - Motion Event Handling + + /// 处理鼠标运动事件 (由 MouseInteractionSessionController 的 gestureMotionHandler 调用) + /// 仅当绑定有 movement 动作时才会触发 motion tap, 故此处无需额外检查 + func handleMotionEvent(_ event: CGEvent) { + guard case .pending(let binding, let savedType, let savedCode, let cursorPos, var dx, var dy) = state else { + return + } + guard binding.hasAnyMovementAction else { return } + + // 累积 delta + let deltaX = Double(event.getIntegerValueField(.mouseEventDeltaX)) + let deltaY = Double(event.getIntegerValueField(.mouseEventDeltaY)) + dx += deltaX + dy += deltaY + + // 更新 pending 状态中的累积值 + state = .pending( + binding: binding, + savedCGEventType: savedType, + savedButtonCode: savedCode, + cursorPosition: cursorPos, + accumulatedDX: dx, + accumulatedDY: dy + ) + + // 尝试识别方向 + if let direction = resolveDirection(dx: dx, dy: dy, threshold: binding.threshold) { + // 方向确定 → 执行动作 + if let actionName = binding.action(for: direction), !actionName.isEmpty { + ShortcutExecutor.shared.execute(named: actionName) + } + state = .active(binding: binding) + } + } + + // MARK: - Scroll Event Handling + + /// 处理滚轮事件 (由 ScrollCore.scrollEventCallBack 调用) + /// Scroll 动作与 Movement 动作完全独立, 使用独立的 pendingScrollDY 累积器 + /// + /// 消费规则 (防止"滚动冲突"): + /// - 只消费该方向上有配置动作的滚轮事件; 无动作的方向立即放行, 正常滚动 + /// - 换向时重置累积器 + /// - active 状态: 仅消费有动作的方向 (防止意外平滑) + /// + /// Delta 读取优先级 (兼容离散 + 平滑滚轮): + /// fixedPt (浮点) → 整数行数; 跳过 pixel delta (单位不一致) + /// + /// - Returns: true 表示事件已被手势系统消费 (ScrollCore 应 return nil) + func handleScrollEvent(_ event: CGEvent) -> Bool { + switch state { + case .pending(let binding, _, _, _, _, _): + guard binding.hasAnyScrollAction else { return false } + + // 使用 fixedPt 浮点值 (兼容平滑滚轮), 回退到整数行数 + let fixedPt = event.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1) + let rawDelta = fixedPt != 0.0 + ? fixedPt + : Double(event.getIntegerValueField(.scrollWheelEventDeltaAxis1)) + guard rawDelta != 0 else { return false } + + // 轴1: 向上为负, 向下为正 + let eventDirection: GestureDirection = rawDelta < 0 ? .up : .down + + // 无动作的方向: 立即放行, 不积累 (防止该方向的滚动被吃掉) + guard binding.scrollAction(for: eventDirection) != nil else { + pendingScrollDY = 0 + return false + } + + // 换向时重置累积器 + if pendingScrollDY != 0 && rawDelta * pendingScrollDY < 0 { + pendingScrollDY = 0 + } + pendingScrollDY += rawDelta + + if abs(pendingScrollDY) >= binding.scrollThreshold { + if let actionName = binding.scrollAction(for: eventDirection), !actionName.isEmpty { + ShortcutExecutor.shared.execute(named: actionName) + } + pendingScrollDY = 0 + state = .active(binding: binding) + } + return true // 消费: 正在向有动作的方向积累 + + case .active(let binding): + // 触发键仍持按中: scroll 手势可重复触发 (每次到达阈值即执行) + // 无动作的方向仍正常放行; 有动作的方向继续消费并在阈值时重新执行 + let fixedPt = event.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1) + let rawDelta = fixedPt != 0.0 + ? fixedPt + : Double(event.getIntegerValueField(.scrollWheelEventDeltaAxis1)) + guard rawDelta != 0 else { return false } + let direction: GestureDirection = rawDelta < 0 ? .up : .down + + guard binding.scrollAction(for: direction) != nil else { return false } + + if pendingScrollDY != 0 && rawDelta * pendingScrollDY < 0 { + pendingScrollDY = 0 + } + pendingScrollDY += rawDelta + + if abs(pendingScrollDY) >= binding.scrollThreshold { + if let actionName = binding.scrollAction(for: direction), !actionName.isEmpty { + ShortcutExecutor.shared.execute(named: actionName) + } + pendingScrollDY = 0 + } + return true + + case .idle: + return false + } + } + + // MARK: - Direction Resolution + + /// 判断主导方向 + /// - 主轴 delta 绝对值需超过 threshold + /// - 主轴 delta 绝对值需超过另一轴 delta 绝对值的 1.5 倍 (防止斜向误触) + func resolveDirection(dx: Double, dy: Double, threshold: Double) -> GestureDirection? { + let absDX = abs(dx) + let absDY = abs(dy) + let diagonalRatio = 1.5 + + if absDX >= absDY { + // 水平主导 + guard absDX >= threshold else { return nil } + guard absDY == 0 || absDX / absDY >= diagonalRatio else { return nil } + return dx > 0 ? .right : .left + } else { + // 垂直主导 + guard absDY >= threshold else { return nil } + guard absDX == 0 || absDY / absDX >= diagonalRatio else { return nil } + // CGEvent deltaY: 正值 = 鼠标向下移动 + return dy > 0 ? .down : .up + } + } + + // MARK: - Clear State + + /// 清空所有手势状态 (ButtonCore disable 时调用) + func clearState() { + state = .idle + pendingScrollDY = 0 + stopGestureTracking() + } + + // MARK: - Private Helpers + + private func handleDownInIdle(event: InputEvent, cgEvent: CGEvent) -> InputResult { + refreshCacheIfNeeded() + + guard let binding = findMatchingBinding(for: event) else { + return .passthrough + } + + // 记录光标位置 (CGEvent 坐标: 左上角为原点) + let cursorPosition = cgEvent.location + + // 推算触发按键对应的 CGEventType (用于回放) + let savedCGEventType: CGEventType + switch event.type { + case .mouse: + switch event.code { + case 0: savedCGEventType = .leftMouseDown + case 1: savedCGEventType = .rightMouseDown + default: savedCGEventType = .otherMouseDown + } + case .keyboard: + savedCGEventType = .keyDown + } + + pendingScrollDY = 0 + state = .pending( + binding: binding, + savedCGEventType: savedCGEventType, + savedButtonCode: event.code, + cursorPosition: cursorPosition, + accumulatedDX: 0, + accumulatedDY: 0 + ) + + // motion tap 只在有 movement 动作时才需要 + if binding.hasAnyMovementAction { + startGestureTracking() + } + return .consumed + } + + /// 查找最匹配的手势绑定 (优先级: modifier 数量最多的优先, 与 ButtonUtils 一致) + /// 鼠标事件允许额外 modifier (用户可能在持握 modifier 键时按下触发按键) + private func findMatchingBinding(for event: InputEvent) -> GestureBinding? { + refreshCacheIfNeeded() + var bestBinding: GestureBinding? = nil + var bestPriority = -1 + for binding in cachedBindings { + if let priority = binding.triggerEvent.matchPriority(for: event), priority > bestPriority { + bestPriority = priority + bestBinding = binding + } + } + return bestBinding + } + + /// 开始手势追踪 (通知 MouseInteractionSessionController 保持 motion tap 运行) + private func startGestureTracking() { + MouseInteractionSessionController.shared.setGestureTracking(true) + } + + /// 停止手势追踪 + private func stopGestureTracking() { + MouseInteractionSessionController.shared.setGestureTracking(false) + } + + /// 回放原始鼠标点击 (手势阈值未达到时, 恢复原始按键行为) + private func replayOriginalClick(eventType: CGEventType, buttonCode: UInt16, position: CGPoint) { + guard let source = CGEventSource(stateID: .hidSystemState) else { return } + + // 确定 mouseButton 参数 + let mouseButton: CGMouseButton + switch eventType { + case .leftMouseDown: mouseButton = .left + case .rightMouseDown: mouseButton = .right + default: mouseButton = .center + } + + // 发送 mouseDown 事件 + if let downEvent = CGEvent(mouseEventSource: source, mouseType: eventType, mouseCursorPosition: position, mouseButton: mouseButton) { + if eventType == .otherMouseDown { + downEvent.setIntegerValueField(.mouseEventButtonNumber, value: Int64(buttonCode)) + } + downEvent.setIntegerValueField(.eventSourceUserData, value: MosEventMarker.syntheticCustom) + downEvent.post(tap: .cghidEventTap) + } + + // 确定对应的 mouseUp 事件类型 + let upEventType: CGEventType + switch eventType { + case .leftMouseDown: upEventType = .leftMouseUp + case .rightMouseDown: upEventType = .rightMouseUp + default: upEventType = .otherMouseUp + } + + // 发送 mouseUp 事件 + if let upEvent = CGEvent(mouseEventSource: source, mouseType: upEventType, mouseCursorPosition: position, mouseButton: mouseButton) { + if upEventType == .otherMouseUp { + upEvent.setIntegerValueField(.mouseEventButtonNumber, value: Int64(buttonCode)) + } + upEvent.setIntegerValueField(.eventSourceUserData, value: MosEventMarker.syntheticCustom) + upEvent.post(tap: .cghidEventTap) + } + } +} diff --git a/Mos/InputEvent/MouseInteractionSessionController.swift b/Mos/InputEvent/MouseInteractionSessionController.swift index de137b38..3963a5af 100644 --- a/Mos/InputEvent/MouseInteractionSessionController.swift +++ b/Mos/InputEvent/MouseInteractionSessionController.swift @@ -43,6 +43,18 @@ final class MouseInteractionSessionController { var activeSessionCount: Int { activeSessions.count } private var hasActiveVirtualModifiers: Bool { InputProcessor.shared.activeModifierFlags != 0 } + // MARK: - Gesture Motion Support + /// 手势运动事件处理回调 (由 GestureProcessor 注册, 在 motion tap 事件中调用) + var gestureMotionHandler: ((CGEvent) -> Void)? + /// 是否有活跃的手势追踪会话 + private(set) var hasActiveGesture = false + + /// 设置手势追踪状态 (由 GestureProcessor 调用) + func setGestureTracking(_ active: Bool) { + hasActiveGesture = active + refreshMotionTapState() + } + init( startMotionTap: (() -> Void)? = nil, stopMotionTap: (() -> Void)? = nil @@ -183,7 +195,7 @@ final class MouseInteractionSessionController { } private var shouldKeepMotionTapRunning: Bool { - !activeSessions.isEmpty || hasActiveVirtualModifiers + !activeSessions.isEmpty || hasActiveVirtualModifiers || hasActiveGesture } private func rewrite(_ event: CGEvent, as target: SyntheticMouseTarget) { @@ -270,6 +282,9 @@ final class MouseInteractionSessionController { return Unmanaged.passUnretained(event) } + // 手势运动处理 (仅读取 delta 值, 不修改事件) + gestureMotionHandler?(event) + rewriteMouseInteractionEvent(event) return Unmanaged.passUnretained(event) } diff --git a/Mos/Localizable.xcstrings b/Mos/Localizable.xcstrings index 8cdeeb7b..f0d29dea 100755 --- a/Mos/Localizable.xcstrings +++ b/Mos/Localizable.xcstrings @@ -300,6 +300,35 @@ } } }, + "bindings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bindings" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按键绑定" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "按鍵綁定" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バインディング" + } + } + } + }, "bold" : { "extractionState" : "manual", "localizations" : { @@ -2466,6 +2495,35 @@ } } }, + "down" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Down" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向下" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向下" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "下" + } + } + } + }, "doNotDisturb" : { "extractionState" : "manual", "localizations" : { @@ -3231,6 +3289,151 @@ "Hello, this is a toast message" : { "comment" : "Toast debug default message" }, + "gestureNone" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "None" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无动作" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無動作" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "なし" + } + } + } + }, + "gestureMovement" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Movement" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "移動" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "移動" + } + } + } + }, + "gestureScroll" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scroll" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "滚轮" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滾輪" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スクロール" + } + } + } + }, + "delete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "削除" + } + } + } + }, + "gestures" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestures" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "鼠标手势" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠手勢" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ジェスチャー" + } + } + } + }, "hideApplication" : { "extractionState" : "manual", "localizations" : { @@ -3593,6 +3796,35 @@ } } }, + "left" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Left" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向左" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向左" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "左" + } + } + } + }, "lockScreen" : { "extractionState" : "manual", "localizations" : { @@ -7717,6 +7949,35 @@ "SCENARIO TESTS" : { "comment" : "Toast debug section header" }, + "right" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Right" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向右" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向右" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "右" + } + } + } + }, "screenshot" : { "extractionState" : "manual", "localizations" : { @@ -9273,6 +9534,35 @@ } } }, + "up" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Up" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向上" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向上" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "上" + } + } + } + }, "undo" : { "extractionState" : "manual", "localizations" : { diff --git a/Mos/Options/Options.swift b/Mos/Options/Options.swift index 9aef3a0c..504de52c 100644 --- a/Mos/Options/Options.swift +++ b/Mos/Options/Options.swift @@ -46,6 +46,10 @@ struct OptionItem { static let Allowlist = "allowlist" static let Applications = "applications" } + + struct Gesture { + static let Bindings = "gestureBindings" + } } class Options { @@ -72,6 +76,10 @@ class Options { var buttons = OPTIONS_BUTTONS_DEFAULT() { didSet { Options.shared.saveOptions() } } + // 手势绑定 + var gestures = OPTIONS_GESTURES_DEFAULT() { + didSet { Options.shared.saveOptions() } + } // 应用 var application = OPTIONS_APPLICATION_DEFAULT() { didSet { Options.shared.saveOptions() } @@ -133,6 +141,8 @@ extension Options { // 按钮绑定 buttons.binding = loadButtonsData() ButtonUtils.shared.invalidateCache() + // 手势绑定 + gestures.binding = loadGestureBindingsData() // 应用 application.allowlist = UserDefaults.standard.bool(forKey: OptionItem.Application.Allowlist) application.applications = loadApplicationsData() @@ -174,6 +184,8 @@ extension Options { } // 按钮绑定 saveButtonBindingsData() + // 手势绑定 + saveGestureBindingsData() } } @@ -207,6 +219,36 @@ extension Options { } } + // 安全加载手势绑定数据 + private func loadGestureBindingsData() -> [GestureBinding] { + let rawValue = UserDefaults.standard.object(forKey: OptionItem.Gesture.Bindings) + guard let data = rawValue as? Data else { + if rawValue != nil { + NSLog("Gesture bindings data has wrong type: \(type(of: rawValue)), clearing corrupted data") + UserDefaults.standard.removeObject(forKey: OptionItem.Gesture.Bindings) + } + return [] + } + + do { + return try decoder.decode([GestureBinding].self, from: data) + } catch { + NSLog("Failed to decode gesture bindings data: \(error), resetting to defaults") + UserDefaults.standard.removeObject(forKey: OptionItem.Gesture.Bindings) + return [] + } + } + + // 保存手势绑定数据 + private func saveGestureBindingsData() { + do { + let data = try encoder.encode(gestures.binding) + UserDefaults.standard.set(data, forKey: OptionItem.Gesture.Bindings) + } catch { + NSLog("Failed to encode gesture bindings data: \(error), skipping save") + } + } + // 加载滚动热键 (支持从旧版 Int 格式迁移) private func loadScrollHotkey(forKey key: String, default defaultValue: ScrollHotkey?) -> ScrollHotkey? { let rawValue = UserDefaults.standard.object(forKey: key) diff --git a/Mos/ScrollCore/ScrollCore.swift b/Mos/ScrollCore/ScrollCore.swift index 6a1f0e44..3adc57e0 100644 --- a/Mos/ScrollCore/ScrollCore.swift +++ b/Mos/ScrollCore/ScrollCore.swift @@ -64,6 +64,10 @@ class ScrollCore { #endif return Unmanaged.passUnretained(event) } + // 手势滚轮模式: 如果有 pending 的滚轮手势, 消费此滚轮事件并识别方向 + if GestureProcessor.shared.handleScrollEvent(event) { + return nil + } // 不处理触控板 // 无法区分黑苹果, 因为黑苹果的触控板驱动直接模拟鼠标输入 // 无法区分 Magic Mouse, 因为其滚动特征与内置的 Trackpad 一致 diff --git a/Mos/Utils/Constants.swift b/Mos/Utils/Constants.swift index 75294ee5..fd0dba11 100644 --- a/Mos/Utils/Constants.swift +++ b/Mos/Utils/Constants.swift @@ -124,6 +124,13 @@ class OPTIONS_BUTTONS_DEFAULT: Codable { } } +// 手势 +class OPTIONS_GESTURES_DEFAULT: Codable { + var binding: [GestureBinding] = [] { + didSet { Options.shared.saveOptions() } + } +} + // 滚动 class OPTIONS_SCROLL_DEFAULT: Codable { var smooth = true { diff --git a/Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift b/Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift new file mode 100644 index 00000000..94c8f84e --- /dev/null +++ b/Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift @@ -0,0 +1,448 @@ +// +// GestureTableCellView.swift +// Mos +// 鼠标手势绑定表格单元格视图 +// Created by Claude on 2026/4/15. +// Copyright © 2026 Caldis. All rights reserved. +// + +import Cocoa + +class GestureTableCellView: NSTableCellView, NSMenuDelegate { + + // MARK: - UI Components + + private var keyPreview: KeyPreview! + + // Movement popups (4 directions: ↑↓←→) + private var upPopUp: NSPopUpButton! + private var downPopUp: NSPopUpButton! + private var leftPopUp: NSPopUpButton! + private var rightPopUp: NSPopUpButton! + + // Scroll popups (2 directions: ↑↓ only) + private var scrollUpPopUp: NSPopUpButton! + private var scrollDownPopUp: NSPopUpButton! + + // MARK: - State + + /// Per-direction current movement action identifier (nil = unbound) + private var currentActions: [GestureDirection: String?] = [ + .up: nil, .down: nil, .left: nil, .right: nil, + ] + + /// Per-direction current scroll action identifier (nil = unbound) + private var currentScrollActions: [GestureDirection: String?] = [ + .up: nil, .down: nil, + ] + + // MARK: - Callbacks + + private var onMovementActionChanged: ((GestureDirection, SystemShortcut.Shortcut?) -> Void)? + private var onScrollActionChanged: ((GestureDirection, SystemShortcut.Shortcut?) -> Void)? + private var onDeleteRequested: (() -> Void)? + + // MARK: - Tags + // Movement popup tags match GestureDirection.allCases index: up=0, down=1, left=2, right=3 + // Scroll popup tags: scrollUp=10, scrollDown=11 + + private static let tagForDirection: [GestureDirection: Int] = { + var map: [GestureDirection: Int] = [:] + for (index, direction) in GestureDirection.allCases.enumerated() { + map[direction] = index + } + return map + }() + + private static let scrollTagUp = 10 + private static let scrollTagDown = 11 + + private func direction(forMovementTag tag: Int) -> GestureDirection? { + return GestureDirection.allCases.indices.contains(tag) + ? GestureDirection.allCases[tag] + : nil + } + + private func movementPopUp(for direction: GestureDirection) -> NSPopUpButton { + switch direction { + case .up: return upPopUp + case .down: return downPopUp + case .left: return leftPopUp + case .right: return rightPopUp + } + } + + private func scrollPopUp(for direction: GestureDirection) -> NSPopUpButton? { + switch direction { + case .up: return scrollUpPopUp + case .down: return scrollDownPopUp + default: return nil + } + } + + // MARK: - Initialization + + override init(frame frameRect: NSRect) { + super.init(frame: .zero) + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("GestureTableCellView must be created programmatically") + } + + // MARK: - Layout + + private func setupLayout() { + // Right-click context menu for deletion + let contextMenu = NSMenu() + let deleteItem = NSMenuItem( + title: NSLocalizedString("delete", comment: ""), + action: #selector(deleteGesture(_:)), + keyEquivalent: "" + ) + deleteItem.target = self + contextMenu.addItem(deleteItem) + self.menu = contextMenu + + // --- KeyPreview --- + keyPreview = KeyPreview() + keyPreview.translatesAutoresizingMaskIntoConstraints = false + addSubview(keyPreview) + + // --- Movement section --- + let movementSection = makeSectionView( + title: NSLocalizedString("gestureMovement", comment: ""), + content: makeMovementStack() + ) + movementSection.translatesAutoresizingMaskIntoConstraints = false + addSubview(movementSection) + + // --- Scroll section --- + let scrollSection = makeSectionView( + title: NSLocalizedString("gestureScroll", comment: ""), + content: makeScrollStack() + ) + scrollSection.translatesAutoresizingMaskIntoConstraints = false + addSubview(scrollSection) + + // --- Auto Layout --- + NSLayoutConstraint.activate([ + // KeyPreview: left-anchored, vertically centered + keyPreview.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + keyPreview.centerYAnchor.constraint(equalTo: centerYAnchor), + + // Movement section: right of keyPreview, vertically centered + movementSection.leadingAnchor.constraint(equalTo: centerXAnchor, constant: -120), + movementSection.centerYAnchor.constraint(equalTo: centerYAnchor), + + // Scroll section: right of movement section, vertically centered + scrollSection.leadingAnchor.constraint(equalTo: movementSection.trailingAnchor, constant: 16), + scrollSection.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), + scrollSection.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + /// Wraps a title label + content view into a labeled section container. + private func makeSectionView(title: String, content: NSView) -> NSView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + + let label = NSTextField(labelWithString: title) + label.font = NSFont.systemFont(ofSize: 11, weight: .medium) + label.textColor = NSColor.secondaryLabelColor + label.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(label) + + content.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(content) + + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: container.topAnchor), + label.leadingAnchor.constraint(equalTo: container.leadingAnchor), + + content.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 4), + content.leadingAnchor.constraint(equalTo: container.leadingAnchor), + content.trailingAnchor.constraint(equalTo: container.trailingAnchor), + content.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + return container + } + + /// Vertical stack of 4 movement direction rows (↑↓←→ in visual order: up, left, right, down). + private func makeMovementStack() -> NSView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + let visualOrder: [GestureDirection] = [.up, .left, .right, .down] + for direction in visualOrder { + stack.addArrangedSubview(makeMovementRow(for: direction)) + } + return stack + } + + /// Vertical stack of 2 scroll direction rows (↑↓). + private func makeScrollStack() -> NSView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + for direction in [GestureDirection.up, .down] { + stack.addArrangedSubview(makeScrollRow(for: direction)) + } + return stack + } + + /// Single horizontal row for a movement direction: [arrow label] [popup] + private func makeMovementRow(for direction: GestureDirection) -> NSView { + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.translatesAutoresizingMaskIntoConstraints = false + popup.tag = GestureTableCellView.tagForDirection[direction] ?? 0 + popup.widthAnchor.constraint(equalToConstant: 180).isActive = true + + switch direction { + case .up: upPopUp = popup + case .down: downPopUp = popup + case .left: leftPopUp = popup + case .right: rightPopUp = popup + } + + return makeDirectionRow(arrowSymbol: direction.arrowSymbol, popup: popup) + } + + /// Single horizontal row for a scroll direction: [arrow label] [popup] + private func makeScrollRow(for direction: GestureDirection) -> NSView { + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.translatesAutoresizingMaskIntoConstraints = false + popup.tag = direction == .up ? GestureTableCellView.scrollTagUp + : GestureTableCellView.scrollTagDown + popup.widthAnchor.constraint(equalToConstant: 180).isActive = true + + switch direction { + case .up: scrollUpPopUp = popup + case .down: scrollDownPopUp = popup + default: break + } + + return makeDirectionRow(arrowSymbol: direction.arrowSymbol, popup: popup) + } + + /// Generic [arrow label] [popup] row. + private func makeDirectionRow(arrowSymbol: String, popup: NSPopUpButton) -> NSView { + let row = NSView() + row.translatesAutoresizingMaskIntoConstraints = false + + let label = NSTextField(labelWithString: arrowSymbol) + label.font = NSFont.systemFont(ofSize: 13) + label.alignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + row.addSubview(label) + row.addSubview(popup) + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: row.leadingAnchor), + label.widthAnchor.constraint(equalToConstant: 20), + label.centerYAnchor.constraint(equalTo: row.centerYAnchor), + + popup.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 4), + popup.trailingAnchor.constraint(equalTo: row.trailingAnchor), + popup.centerYAnchor.constraint(equalTo: row.centerYAnchor), + + row.heightAnchor.constraint(equalTo: popup.heightAnchor), + ]) + return row + } + + // MARK: - Configure + + func configure( + with binding: GestureBinding, + onMovementActionChanged: @escaping (GestureDirection, SystemShortcut.Shortcut?) -> Void, + onScrollActionChanged: @escaping (GestureDirection, SystemShortcut.Shortcut?) -> Void, + onDeleteRequested: @escaping () -> Void + ) { + self.onMovementActionChanged = onMovementActionChanged + self.onScrollActionChanged = onScrollActionChanged + self.onDeleteRequested = onDeleteRequested + + // Update movement state cache + currentActions[.up] = binding.upAction + currentActions[.down] = binding.downAction + currentActions[.left] = binding.leftAction + currentActions[.right] = binding.rightAction + + // Update scroll state cache + currentScrollActions[.up] = binding.scrollUpAction + currentScrollActions[.down] = binding.scrollDownAction + + // KeyPreview + keyPreview.update(from: binding.triggerEvent.displayComponents, status: .normal) + + // Build movement menus + for direction in GestureDirection.allCases { + setupMovementPopUp(movementPopUp(for: direction), direction: direction, actionName: binding.action(for: direction)) + } + + // Build scroll menus + for direction in [GestureDirection.up, .down] { + if let popup = scrollPopUp(for: direction) { + setupScrollPopUp(popup, direction: direction, actionName: binding.scrollAction(for: direction)) + } + } + } + + // MARK: - PopUp Setup + + private func setupMovementPopUp(_ popup: NSPopUpButton, direction: GestureDirection, actionName: String?) { + let menu = NSMenu() + menu.delegate = self + ShortcutManager.buildShortcutMenu(into: menu, target: self, action: #selector(movementShortcutSelected(_:)), showLogiActions: false) + disableKeyEquivalents(in: menu) + popup.menu = menu + refreshDisplay(for: popup, actionName: actionName) + } + + private func setupScrollPopUp(_ popup: NSPopUpButton, direction: GestureDirection, actionName: String?) { + let menu = NSMenu() + menu.delegate = self + ShortcutManager.buildShortcutMenu(into: menu, target: self, action: #selector(scrollShortcutSelected(_:)), showLogiActions: false) + disableKeyEquivalents(in: menu) + popup.menu = menu + refreshDisplay(for: popup, actionName: actionName) + } + + /// Updates the placeholder item (index 0) of the popup to show the current action. + private func refreshDisplay(for popup: NSPopUpButton, actionName: String?) { + guard let menu = popup.menu, + let placeholderItem = menu.items.first else { return } + + if let name = actionName, let shortcut = SystemShortcut.getShortcut(named: name) { + placeholderItem.title = shortcut.localizedName + } else { + placeholderItem.title = NSLocalizedString("gestureNone", comment: "") + } + placeholderItem.image = nil + popup.selectItem(at: 0) + popup.synchronizeTitleAndSelectedItem() + } + + // MARK: - Actions + + @objc private func movementShortcutSelected(_ sender: NSMenuItem) { + guard let popup = findPopUpInMovement(containing: sender), + let direction = direction(forMovementTag: popup.tag) else { return } + + if sender.representedObject as? String == "__custom__" { return } + + let shortcut = sender.representedObject as? SystemShortcut.Shortcut + currentActions[direction] = shortcut?.identifier + refreshDisplay(for: popup, actionName: shortcut?.identifier) + onMovementActionChanged?(direction, shortcut) + } + + @objc private func scrollShortcutSelected(_ sender: NSMenuItem) { + guard let popup = findPopUpInScroll(containing: sender) else { return } + + if sender.representedObject as? String == "__custom__" { return } + + let direction: GestureDirection = popup === scrollUpPopUp ? .up : .down + let shortcut = sender.representedObject as? SystemShortcut.Shortcut + currentScrollActions[direction] = shortcut?.identifier + refreshDisplay(for: popup, actionName: shortcut?.identifier) + onScrollActionChanged?(direction, shortcut) + } + + @objc private func deleteGesture(_ sender: NSMenuItem) { + onDeleteRequested?() + } + + // MARK: - PopUp Lookup + + private func findPopUpInMovement(containing item: NSMenuItem) -> NSPopUpButton? { + let allPopUps: [NSPopUpButton] = [upPopUp, downPopUp, leftPopUp, rightPopUp] + return allPopUps.first { menuContains($0.menu, item: item) } + } + + private func findPopUpInScroll(containing item: NSMenuItem) -> NSPopUpButton? { + return [scrollUpPopUp, scrollDownPopUp].first { menuContains($0?.menu, item: item) } ?? nil + } + + private func menuContains(_ menu: NSMenu?, item: NSMenuItem) -> Bool { + guard let menu = menu else { return false } + for menuItem in menu.items { + if menuItem === item { return true } + if let sub = menuItem.submenu, menuContains(sub, item: item) { return true } + } + return false + } +} + +// MARK: - NSMenuDelegate + +extension GestureTableCellView { + + func menuWillOpen(_ menu: NSMenu) { + adjustMenuStructure(menu) + enableKeyEquivalents(in: menu) + } + + func menuDidClose(_ menu: NSMenu) { + disableKeyEquivalents(in: menu) + } + + /// Dynamically adjusts placeholder / unbound item visibility. + private func adjustMenuStructure(_ menu: NSMenu) { + guard menu.items.count >= 3 else { return } + + // Determine current action name for this menu + let currentActionName: String?? + + if let ownerPopup = [upPopUp, downPopUp, leftPopUp, rightPopUp] + .first(where: { $0.menu === menu }), + let direction = direction(forMovementTag: ownerPopup.tag) { + currentActionName = currentActions[direction] + } else if let ownerPopup = [scrollUpPopUp, scrollDownPopUp] + .first(where: { $0?.menu === menu }) { + let direction: GestureDirection = ownerPopup === scrollUpPopUp ? .up : .down + currentActionName = currentScrollActions[direction] + } else { + return + } + + let placeholderItem = menu.items[0] + let firstSeparator = menu.items[1] + let unboundItem = menu.items[2] + + let hasBoundAction = (currentActionName ?? nil) != nil + + if hasBoundAction { + placeholderItem.isHidden = false + firstSeparator.isHidden = false + unboundItem.title = NSLocalizedString("unbind", comment: "") + } else { + placeholderItem.isHidden = true + firstSeparator.isHidden = true + unboundItem.title = NSLocalizedString("unbound", comment: "") + } + } + + private func enableKeyEquivalents(in menu: NSMenu) { + for item in menu.items { + if let shortcut = item.representedObject as? SystemShortcut.Shortcut { + let keyEquivalent = shortcut.keyEquivalent + item.keyEquivalent = keyEquivalent.keyEquivalent + item.keyEquivalentModifierMask = keyEquivalent.modifierMask + } + if let submenu = item.submenu { enableKeyEquivalents(in: submenu) } + } + } + + private func disableKeyEquivalents(in menu: NSMenu) { + for item in menu.items { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + if let submenu = item.submenu { disableKeyEquivalents(in: submenu) } + } + } +} diff --git a/Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift b/Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift index e65fde11..a1edf7fa 100644 --- a/Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift +++ b/Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift @@ -1,13 +1,22 @@ // // PreferencesButtonsViewController.swift // Mos -// 按钮绑定配置界面 +// 按钮绑定 + 鼠标手势配置界面 // Created by Claude on 2025/8/10. // Copyright © 2025年 Caldis. All rights reserved. // import Cocoa +// MARK: - ViewMode + +private enum ViewMode { + case bindings + case gestures +} + +// MARK: - PreferencesButtonsViewController + class PreferencesButtonsViewController: NSViewController { // MARK: - Recorder @@ -15,6 +24,10 @@ class PreferencesButtonsViewController: NSViewController { // MARK: - Data private var buttonBindings: [ButtonBinding] = [] + private var gestureBindings: [GestureBinding] = [] + + // MARK: - Mode + private var currentMode: ViewMode = .bindings // MARK: - UI Elements // 表格 @@ -26,89 +39,150 @@ class PreferencesButtonsViewController: NSViewController { @IBOutlet weak var createButton: PrimaryButton! @IBOutlet weak var addButton: NSButton! @IBOutlet weak var delButton: NSButton! - + + // 模式切换 + private var segmentedControl: NSSegmentedControl! + + // 手势 Cell 标识 + private static let gestureCellIdentifier = NSUserInterfaceItemIdentifier("gestureCellView") + override func viewDidLoad() { super.viewDidLoad() - // 设置代理 recorder.delegate = self tableView.delegate = self tableView.dataSource = self - // 读取设置 + setupSegmentedControl() loadOptionsToView() } - + override func viewWillAppear() { - // 检查表格数据 toggleNoDataHint() - // 设置录制按钮回调 setupRecordButtonCallback() } - // 添加 + // MARK: - Segmented Control Setup + + private func setupSegmentedControl() { + let labels = [ + NSLocalizedString("bindings", comment: ""), + NSLocalizedString("gestures", comment: ""), + ] + segmentedControl = NSSegmentedControl( + labels: labels, + trackingMode: .selectOne, + target: self, + action: #selector(segmentChanged(_:)) + ) + segmentedControl.selectedSegment = 0 + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + tableFoot.addSubview(segmentedControl) + + // Move +/- buttons to the trailing edge so they don't overlap the segmented control. + // The storyboard placed addButton at leading+6 and delButton immediately after it. + // Deactivate those conflicting constraints before adding new trailing ones. + for constraint in tableFoot.constraints { + let firstView = constraint.firstItem as? NSView + let secondView = constraint.secondItem as? NSView + let touchesButton = (firstView === addButton || firstView === delButton || + secondView === addButton || secondView === delButton) + if touchesButton { + constraint.isActive = false + } + } + + NSLayoutConstraint.activate([ + // Segmented control: leading + segmentedControl.leadingAnchor.constraint(equalTo: tableFoot.leadingAnchor, constant: 8), + segmentedControl.centerYAnchor.constraint(equalTo: tableFoot.centerYAnchor), + + // Delete button: trailing edge + delButton.trailingAnchor.constraint(equalTo: tableFoot.trailingAnchor, constant: -6), + delButton.centerYAnchor.constraint(equalTo: tableFoot.centerYAnchor), + + // Add button: immediately to the left of delete button + addButton.trailingAnchor.constraint(equalTo: delButton.leadingAnchor, constant: -2), + addButton.centerYAnchor.constraint(equalTo: tableFoot.centerYAnchor), + ]) + } + + @objc private func segmentChanged(_ sender: NSSegmentedControl) { + currentMode = sender.selectedSegment == 0 ? .bindings : .gestures + tableView.reloadData() + toggleNoDataHint() + updateDelButtonState() + } + + // MARK: - 添加/删除 + @IBAction func addItemClick(_ sender: NSButton) { recorder.startRecording(from: sender) } - // 删除 + @IBAction func removeItemClick(_ sender: NSButton) { - // 确保选择了行 guard tableView.selectedRow != -1 else { return } - // 统一通过 removeButtonBinding 处理删除逻辑 - let binding = buttonBindings[tableView.selectedRow] - removeButtonBinding(id: binding.id) - // 更新删除按钮状态 + switch currentMode { + case .bindings: + let binding = buttonBindings[tableView.selectedRow] + removeButtonBinding(id: binding.id) + case .gestures: + let binding = gestureBindings[tableView.selectedRow] + removeGestureBinding(id: binding.id) + } updateDelButtonState() } } -/** - * 数据持久化 - **/ +// MARK: - Data Persistence + extension PreferencesButtonsViewController { - // 从 Options 加载到界面 + func loadOptionsToView() { buttonBindings = Options.shared.buttons.binding + gestureBindings = Options.shared.gestures.binding tableView.reloadData() toggleNoDataHint() } - // 保存界面到 Options, 并同步 divert 状态 - func syncViewWithOptions() { + // 保存按钮绑定并同步 HID++ divert + func syncButtonsWithOptions() { Options.shared.buttons.binding = buttonBindings ButtonUtils.shared.invalidateCache() - // 绑定变更后同步 HID++ divert: 只 divert 有绑定的按键 LogitechHIDManager.shared.syncDivertWithBindings() } - // 更新删除按钮状态 + // 保存手势绑定 + func syncGesturesWithOptions() { + Options.shared.gestures.binding = gestureBindings + GestureProcessor.shared.invalidateCache() + } + func updateDelButtonState() { delButton.isEnabled = tableView.selectedRow != -1 } - // 设置录制按钮回调 private func setupRecordButtonCallback() { createButton.onMouseDown = { [weak self] target in self?.recorder.startRecording(from: target) } } - - private func addRecordedEvent(_ event: InputEvent, isDuplicate: Bool) { - let recordedEvent = RecordedEvent(from: event) + // MARK: - Button Binding CRUD + + private func addButtonRecordedEvent(_ event: InputEvent, isDuplicate: Bool) { + let recordedEvent = RecordedEvent(from: event) if isDuplicate { if let existing = buttonBindings.first(where: { $0.triggerEvent == recordedEvent }) { highlightExistingRow(with: existing.id) } return } - let binding = ButtonBinding(triggerEvent: recordedEvent, systemShortcutName: "", isEnabled: false) buttonBindings.append(binding) tableView.reloadData() toggleNoDataHint() - syncViewWithOptions() + syncButtonsWithOptions() } - // 高亮已存在的行 (用于重复录制的视觉反馈) private func highlightExistingRow(with id: UUID) { guard let row = buttonBindings.firstIndex(where: { $0.id == id }) else { return } tableView.deselectAll(nil) @@ -118,139 +192,201 @@ extension PreferencesButtonsViewController { } } - // 删除按钮绑定 func removeButtonBinding(id: UUID) { buttonBindings.removeAll(where: { $0.id == id }) tableView.reloadData() toggleNoDataHint() - syncViewWithOptions() + syncButtonsWithOptions() } - /// 更新按钮绑定 - /// - Parameters: - /// - id: 绑定记录的唯一标识 - /// - shortcut: 系统快捷键对象,nil 表示清除绑定 func updateButtonBinding(id: UUID, with shortcut: SystemShortcut.Shortcut?) { guard let index = buttonBindings.firstIndex(where: { $0.id == id }) else { return } - - let oldBinding = buttonBindings[index] - - let updatedBinding: ButtonBinding + let old = buttonBindings[index] if let shortcut = shortcut { - // 绑定快捷键:直接使用快捷键的 identifier - updatedBinding = ButtonBinding( - id: oldBinding.id, - triggerEvent: oldBinding.triggerEvent, - systemShortcutName: shortcut.identifier, - isEnabled: true - ) + buttonBindings[index] = ButtonBinding(id: old.id, triggerEvent: old.triggerEvent, systemShortcutName: shortcut.identifier, isEnabled: true) } else { - // 清除绑定:保持触发事件,清空快捷键名称并禁用 - updatedBinding = ButtonBinding( - id: oldBinding.id, - triggerEvent: oldBinding.triggerEvent, - systemShortcutName: "", - isEnabled: false - ) + buttonBindings[index] = ButtonBinding(id: old.id, triggerEvent: old.triggerEvent, systemShortcutName: "", isEnabled: false) } - - buttonBindings[index] = updatedBinding - syncViewWithOptions() + syncButtonsWithOptions() } - /// 更新按钮绑定 (自定义快捷键) func updateButtonBinding(id: UUID, withCustomName name: String) { guard let index = buttonBindings.firstIndex(where: { $0.id == id }) else { return } let old = buttonBindings[index] - buttonBindings[index] = ButtonBinding( - id: old.id, - triggerEvent: old.triggerEvent, - systemShortcutName: name, - isEnabled: true, - createdAt: old.createdAt - ) - syncViewWithOptions() + buttonBindings[index] = ButtonBinding(id: old.id, triggerEvent: old.triggerEvent, systemShortcutName: name, isEnabled: true, createdAt: old.createdAt) + syncButtonsWithOptions() + } + + // MARK: - Gesture Binding CRUD + + private func addGestureRecordedEvent(_ event: InputEvent, isDuplicate: Bool) { + let recordedEvent = RecordedEvent(from: event) + if isDuplicate { + // 高亮已存在的手势行 (无特殊 highlight 方法, 只滚动到可见) + if let row = gestureBindings.firstIndex(where: { $0.triggerEvent == recordedEvent }) { + tableView.deselectAll(nil) + tableView.scrollRowToVisible(row) + } + return + } + let binding = GestureBinding(triggerEvent: recordedEvent) + gestureBindings.append(binding) + tableView.reloadData() + toggleNoDataHint() + syncGesturesWithOptions() + } + + func removeGestureBinding(id: UUID) { + gestureBindings.removeAll(where: { $0.id == id }) + tableView.reloadData() + toggleNoDataHint() + syncGesturesWithOptions() + } + + func updateGestureBinding(id: UUID, direction: GestureDirection, shortcut: SystemShortcut.Shortcut?) { + guard let index = gestureBindings.firstIndex(where: { $0.id == id }) else { return } + gestureBindings[index] = gestureBindings[index].withAction(shortcut?.identifier, for: direction) + syncGesturesWithOptions() + } + + func updateGestureScrollAction(id: UUID, direction: GestureDirection, shortcut: SystemShortcut.Shortcut?) { + guard let index = gestureBindings.firstIndex(where: { $0.id == id }) else { return } + gestureBindings[index] = gestureBindings[index].withScrollAction(shortcut?.identifier, for: direction) + syncGesturesWithOptions() } } -/** - * 表格区域渲染及操作 - **/ +// MARK: - Table View Delegate & Data Source + extension PreferencesButtonsViewController: NSTableViewDelegate, NSTableViewDataSource { - // 无数据 + func toggleNoDataHint() { - let hasData = buttonBindings.count != 0 + let rowCount = currentRowCount + let hasData = rowCount != 0 updateViewVisibility(view: createButton, visible: !hasData) updateViewVisibility(view: tableEmpty, visible: !hasData) updateViewVisibility(view: tableHead, visible: hasData) - updateViewVisibility(view: tableFoot, visible: hasData) + // tableFoot always visible so the segmented control stays accessible + updateViewVisibility(view: tableFoot, visible: true) } + private func updateViewVisibility(view: NSView, visible: Bool) { view.isHidden = !visible view.animator().alphaValue = visible ? 1 : 0 } - - // 表格数据源 + + private var currentRowCount: Int { + switch currentMode { + case .bindings: return buttonBindings.count + case .gestures: return gestureBindings.count + } + } + + // MARK: - Data Source + + func numberOfRows(in tableView: NSTableView) -> Int { + return currentRowCount + } + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { guard let tableColumnIdentifier = tableColumn?.identifier else { return nil } - // 创建 Cell - if let cell = tableView.makeView(withIdentifier: tableColumnIdentifier, owner: self) as? ButtonTableCellView { - let binding = buttonBindings[row] - - cell.configure( - with: binding, - onShortcutSelected: { [weak self] shortcut in - self?.updateButtonBinding(id: binding.id, with: shortcut) - }, - onCustomShortcutRecorded: { [weak self] customName in - self?.updateButtonBinding(id: binding.id, withCustomName: customName) - }, - onDeleteRequested: { [weak self] in - self?.removeButtonBinding(id: binding.id) - } - ) - return cell + switch currentMode { + case .bindings: + if let cell = tableView.makeView(withIdentifier: tableColumnIdentifier, owner: self) as? ButtonTableCellView { + let binding = buttonBindings[row] + cell.configure( + with: binding, + onShortcutSelected: { [weak self] shortcut in + self?.updateButtonBinding(id: binding.id, with: shortcut) + }, + onCustomShortcutRecorded: { [weak self] customName in + self?.updateButtonBinding(id: binding.id, withCustomName: customName) + }, + onDeleteRequested: { [weak self] in + self?.removeButtonBinding(id: binding.id) + } + ) + return cell + } + + case .gestures: + var cell = tableView.makeView(withIdentifier: Self.gestureCellIdentifier, owner: self) as? GestureTableCellView + if cell == nil { + cell = GestureTableCellView(frame: .zero) + cell?.identifier = Self.gestureCellIdentifier + } + if let cell = cell { + let binding = gestureBindings[row] + cell.configure( + with: binding, + onMovementActionChanged: { [weak self] direction, shortcut in + self?.updateGestureBinding(id: binding.id, direction: direction, shortcut: shortcut) + }, + onScrollActionChanged: { [weak self] direction, shortcut in + self?.updateGestureScrollAction(id: binding.id, direction: direction, shortcut: shortcut) + }, + onDeleteRequested: { [weak self] in + self?.removeGestureBinding(id: binding.id) + } + ) + return cell + } } return nil } - - // 行高 + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { - return 44 - } - - // 行数 - func numberOfRows(in tableView: NSTableView) -> Int { - return buttonBindings.count + switch currentMode { + case .bindings: return 44 + case .gestures: return 150 + } } - // 选择变化 func tableViewSelectionDidChange(_ notification: Notification) { updateDelButtonState() } - // Type Selection 支持 func tableView(_ tableView: NSTableView, typeSelectStringFor tableColumn: NSTableColumn?, row: Int) -> String? { - guard row < buttonBindings.count else { return nil } - let components = buttonBindings[row].triggerEvent.displayComponents - // 去掉第一项(修饰键),只保留实际按键用于匹配 - let keyOnly = components.count > 1 ? Array(components.dropFirst()) : components - return keyOnly.joined(separator: " ") + switch currentMode { + case .bindings: + guard row < buttonBindings.count else { return nil } + let components = buttonBindings[row].triggerEvent.displayComponents + let keyOnly = components.count > 1 ? Array(components.dropFirst()) : components + return keyOnly.joined(separator: " ") + case .gestures: + guard row < gestureBindings.count else { return nil } + let components = gestureBindings[row].triggerEvent.displayComponents + let keyOnly = components.count > 1 ? Array(components.dropFirst()) : components + return keyOnly.joined(separator: " ") + } } } -// MARK: - EventRecorderDelegate +// MARK: - KeyRecorderDelegate + extension PreferencesButtonsViewController: KeyRecorderDelegate { + func validateRecordedEvent(_ recorder: KeyRecorder, event: InputEvent) -> Bool { let recordedEvent = RecordedEvent(from: event) - return !buttonBindings.contains(where: { $0.triggerEvent == recordedEvent }) + switch currentMode { + case .bindings: + return !buttonBindings.contains(where: { $0.triggerEvent == recordedEvent }) + case .gestures: + return !gestureBindings.contains(where: { $0.triggerEvent == recordedEvent }) + } } func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: InputEvent, isDuplicate: Bool) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.66) { [weak self] in - self?.addRecordedEvent(event, isDuplicate: isDuplicate) + guard let self = self else { return } + switch self.currentMode { + case .bindings: + self.addButtonRecordedEvent(event, isDuplicate: isDuplicate) + case .gestures: + self.addGestureRecordedEvent(event, isDuplicate: isDuplicate) + } } } } diff --git a/MosTests/GestureProcessorTests.swift b/MosTests/GestureProcessorTests.swift new file mode 100644 index 00000000..c3eba50b --- /dev/null +++ b/MosTests/GestureProcessorTests.swift @@ -0,0 +1,545 @@ +// +// GestureProcessorTests.swift +// MosTests +// GestureProcessor 状态机单元测试 +// Created by Claude on 2026/4/15. +// Copyright © 2026 Caldis. All rights reserved. +// + +import XCTest +@testable import Mos_Debug + +final class GestureProcessorTests: XCTestCase { + + // MARK: - Helpers + + /// 创建用于测试的 GestureBinding (默认中键触发) + /// movement 动作 (4 方向) 和 scroll 动作 (↑↓) 相互独立 + private func makeGestureBinding( + code: UInt16 = 2, // 中键 button code = 2 + upAction: String? = "missionControl", + downAction: String? = "appExpose", + leftAction: String? = "moveSpaceLeft", + rightAction: String? = "moveSpaceRight", + threshold: Double = 30.0, + scrollUpAction: String? = nil, + scrollDownAction: String? = nil, + scrollThreshold: Double = 3.0 + ) -> GestureBinding { + let trigger = RecordedEvent( + type: .mouse, + code: code, + modifiers: 0, + displayComponents: ["🖱3"], + deviceFilter: nil + ) + return GestureBinding( + triggerEvent: trigger, + upAction: upAction, + downAction: downAction, + leftAction: leftAction, + rightAction: rightAction, + threshold: threshold, + scrollUpAction: scrollUpAction, + scrollDownAction: scrollDownAction, + scrollThreshold: scrollThreshold + ) + } + + /// 创建仅有 scroll 动作 (无 movement 动作) 的手势绑定 + private func makeScrollOnlyBinding( + code: UInt16 = 2, + scrollUpAction: String? = "missionControl", + scrollDownAction: String? = "appExpose", + scrollThreshold: Double = 3.0 + ) -> GestureBinding { + return makeGestureBinding( + code: code, + upAction: nil, downAction: nil, leftAction: nil, rightAction: nil, + scrollUpAction: scrollUpAction, + scrollDownAction: scrollDownAction, + scrollThreshold: scrollThreshold + ) + } + + /// 创建用于测试的滚轮手势 CGEvent + private func makeCGScrollEvent(deltaAxis1: Int64, deltaAxis2: Int64 = 0) -> CGEvent { + let event = CGEvent(source: CGEventSource(stateID: .hidSystemState))! + event.type = .scrollWheel + event.setIntegerValueField(.scrollWheelEventDeltaAxis1, value: deltaAxis1) + event.setIntegerValueField(.scrollWheelEventDeltaAxis2, value: deltaAxis2) + return event + } + + /// 创建 mouse InputEvent + private func makeMouseEvent(code: UInt16, phase: InputPhase) -> InputEvent { + return InputEvent( + type: .mouse, + code: code, + modifiers: CGEventFlags(rawValue: 0), + phase: phase, + source: .hidPP, + device: nil + ) + } + + /// 创建最小化 CGEvent (用于 handleButtonEvent 的 cgEvent 参数) + private func makeCGMouseEvent(type: CGEventType = .otherMouseDown) -> CGEvent { + return CGEvent(source: CGEventSource(stateID: .hidSystemState))! + } + + override func setUp() { + super.setUp() + Options.shared.gestures.binding = [] + GestureProcessor.shared.invalidateCache() + GestureProcessor.shared.clearState() + MouseInteractionSessionController.shared.setTestingMotionTapHooks() + } + + override func tearDown() { + GestureProcessor.shared.clearState() + Options.shared.gestures.binding = [] + GestureProcessor.shared.invalidateCache() + MouseInteractionSessionController.shared.clearTestingMotionTapHooks() + super.tearDown() + } + + // MARK: - Direction Resolution Tests + + func testResolveDirection_upwardMovement() { + let direction = GestureProcessor.shared.resolveDirection(dx: 0, dy: -50, threshold: 30) + XCTAssertEqual(direction, .up, "Negative deltaY (mouse up) should resolve to .up") + } + + func testResolveDirection_downwardMovement() { + let direction = GestureProcessor.shared.resolveDirection(dx: 0, dy: 50, threshold: 30) + XCTAssertEqual(direction, .down, "Positive deltaY (mouse down) should resolve to .down") + } + + func testResolveDirection_leftMovement() { + let direction = GestureProcessor.shared.resolveDirection(dx: -50, dy: 0, threshold: 30) + XCTAssertEqual(direction, .left, "Negative deltaX should resolve to .left") + } + + func testResolveDirection_rightMovement() { + let direction = GestureProcessor.shared.resolveDirection(dx: 50, dy: 0, threshold: 30) + XCTAssertEqual(direction, .right, "Positive deltaX should resolve to .right") + } + + func testResolveDirection_belowThreshold_returnsNil() { + let direction = GestureProcessor.shared.resolveDirection(dx: 0, dy: -10, threshold: 30) + XCTAssertNil(direction, "Delta below threshold should not resolve to any direction") + } + + func testResolveDirection_exactlyAtThreshold_resolves() { + let direction = GestureProcessor.shared.resolveDirection(dx: 0, dy: -30, threshold: 30) + XCTAssertEqual(direction, .up, "Delta exactly at threshold should resolve") + } + + func testResolveDirection_diagonal45degrees_returnsNil() { + // Equal x and y movement — no dominant axis, diagonal ratio not met + let direction = GestureProcessor.shared.resolveDirection(dx: 50, dy: -50, threshold: 30) + XCTAssertNil(direction, "45-degree diagonal movement should not trigger any direction") + } + + func testResolveDirection_slightlyOffDiagonal_returnsNil() { + // dx=40, dy=-30: ratio = 40/30 = 1.33 < 1.5 — not dominant enough + let direction = GestureProcessor.shared.resolveDirection(dx: 40, dy: -30, threshold: 30) + XCTAssertNil(direction, "Diagonal movement with insufficient ratio should not trigger") + } + + func testResolveDirection_dominantAxis_passesRatio() { + // dx=60, dy=-10: ratio = 60/10 = 6.0 >= 1.5 — clearly horizontal + let direction = GestureProcessor.shared.resolveDirection(dx: 60, dy: -10, threshold: 30) + XCTAssertEqual(direction, .right, "Clearly dominant horizontal movement should resolve to .right") + } + + func testResolveDirection_pureVertical_noXComponent() { + // dx=0, dy=-50: zero division handled by guard + let direction = GestureProcessor.shared.resolveDirection(dx: 0, dy: -50, threshold: 30) + XCTAssertEqual(direction, .up) + } + + // MARK: - State Machine Tests + + func testHandleButtonEvent_idle_noMatch_passthrough() { + // No gesture bindings registered → passthrough + let event = makeMouseEvent(code: 2, phase: .down) + let cgEvent = makeCGMouseEvent() + let result = GestureProcessor.shared.handleButtonEvent(event, cgEvent: cgEvent) + XCTAssertEqual(result, .passthrough) + } + + func testHandleButtonEvent_idle_match_consumed() { + let binding = makeGestureBinding(code: 2) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + let event = makeMouseEvent(code: 2, phase: .down) + let cgEvent = makeCGMouseEvent() + let result = GestureProcessor.shared.handleButtonEvent(event, cgEvent: cgEvent) + XCTAssertEqual(result, .consumed, "Matching button down should be consumed (gesture pending)") + } + + func testHandleButtonEvent_pending_triggerReleased_consumed() { + let binding = makeGestureBinding(code: 2) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + // Down → pending + let downEvent = makeMouseEvent(code: 2, phase: .down) + _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + + // Up of trigger → consumed (click replay) + let upEvent = makeMouseEvent(code: 2, phase: .up) + let result = GestureProcessor.shared.handleButtonEvent(upEvent, cgEvent: makeCGMouseEvent()) + XCTAssertEqual(result, .consumed, "Releasing trigger without reaching threshold should be consumed") + } + + func testHandleButtonEvent_pending_nonTriggerReleased_passthrough() { + let binding = makeGestureBinding(code: 2) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + // Middle button down → pending + let downEvent = makeMouseEvent(code: 2, phase: .down) + _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + + // Release a DIFFERENT button → passthrough (not the trigger) + let otherUpEvent = makeMouseEvent(code: 3, phase: .up) + let result = GestureProcessor.shared.handleButtonEvent(otherUpEvent, cgEvent: makeCGMouseEvent()) + XCTAssertEqual(result, .passthrough) + } + + func testHandleButtonEvent_afterGestureActive_triggerReleased_consumed() { + let binding = makeGestureBinding(code: 2, threshold: 10.0) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + // Trigger down → pending + let downEvent = makeMouseEvent(code: 2, phase: .down) + _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + + // Simulate motion that crosses threshold (directly call handleMotionEvent) + let motionEvent = makeCGMotionEvent(deltaX: 0, deltaY: -50) + GestureProcessor.shared.handleMotionEvent(motionEvent) + + // Trigger up → consumed (gesture was already fired, just cleanup) + let upEvent = makeMouseEvent(code: 2, phase: .up) + let result = GestureProcessor.shared.handleButtonEvent(upEvent, cgEvent: makeCGMouseEvent()) + XCTAssertEqual(result, .consumed) + } + + // MARK: - GestureBinding Matching Tests + + func testGestureBinding_matchingPriority_higherModifierCountWins() { + // Two bindings: one with modifier, one without — higher modifier count should match + let noMod = RecordedEvent(type: .mouse, code: 2, modifiers: 0, displayComponents: ["🖱3"], deviceFilter: nil) + let withMod = RecordedEvent(type: .mouse, code: 2, modifiers: UInt(CGEventFlags.maskCommand.rawValue), displayComponents: ["⌘", "🖱3"], deviceFilter: nil) + + let bindingNoMod = GestureBinding(triggerEvent: noMod, upAction: "missionControl") + let bindingWithMod = GestureBinding(triggerEvent: withMod, upAction: "appExpose") + + Options.shared.gestures.binding = [bindingNoMod, bindingWithMod] + GestureProcessor.shared.invalidateCache() + + // Event with Command held → should match bindingWithMod (higher priority) + let event = InputEvent( + type: .mouse, code: 2, + modifiers: .maskCommand, + phase: .down, source: .hidPP, device: nil + ) + let result = GestureProcessor.shared.handleButtonEvent(event, cgEvent: makeCGMouseEvent()) + XCTAssertEqual(result, .consumed) + } + + // MARK: - Direction to Action Mapping Tests + + func testGestureBinding_actionForDirection() { + let binding = makeGestureBinding( + upAction: "missionControl", + downAction: "appExpose", + leftAction: "moveSpaceLeft", + rightAction: "moveSpaceRight" + ) + XCTAssertEqual(binding.action(for: .up), "missionControl") + XCTAssertEqual(binding.action(for: .down), "appExpose") + XCTAssertEqual(binding.action(for: .left), "moveSpaceLeft") + XCTAssertEqual(binding.action(for: .right), "moveSpaceRight") + } + + func testGestureBinding_withAction_returnsUpdatedCopy() { + let binding = makeGestureBinding(upAction: nil) + let updated = binding.withAction("missionControl", for: .up) + XCTAssertEqual(updated.action(for: .up), "missionControl") + XCTAssertNil(binding.action(for: .up), "Original should be unchanged") + } + + func testGestureBinding_hasAnyAction_trueWhenAtLeastOneActionSet() { + let binding = GestureBinding( + triggerEvent: RecordedEvent(type: .mouse, code: 2, modifiers: 0, displayComponents: ["🖱3"], deviceFilter: nil), + upAction: "missionControl" + ) + XCTAssertTrue(binding.hasAnyAction) + } + + func testGestureBinding_hasAnyAction_falseWhenNoActions() { + let binding = GestureBinding( + triggerEvent: RecordedEvent(type: .mouse, code: 2, modifiers: 0, displayComponents: ["🖱3"], deviceFilter: nil) + ) + XCTAssertFalse(binding.hasAnyAction) + } + + // MARK: - Motion Tap State Tests + + func testMotionTap_startsWhenGestureBegins() { + let binding = makeGestureBinding(code: 2) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + let startExpectation = expectation(description: "Motion tap should start") + MouseInteractionSessionController.shared.setTestingMotionTapHooks( + start: { startExpectation.fulfill() }, + stop: {} + ) + + let event = makeMouseEvent(code: 2, phase: .down) + _ = GestureProcessor.shared.handleButtonEvent(event, cgEvent: makeCGMouseEvent()) + + waitForExpectations(timeout: 1.0) + } + + func testMotionTap_stopsWhenGestureCancelled() { + let binding = makeGestureBinding(code: 2) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + var stopCalled = false + MouseInteractionSessionController.shared.setTestingMotionTapHooks( + start: {}, + stop: { stopCalled = true } + ) + + // Start gesture + let downEvent = makeMouseEvent(code: 2, phase: .down) + _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + + // Cancel gesture (trigger released without crossing threshold) + let upEvent = makeMouseEvent(code: 2, phase: .up) + _ = GestureProcessor.shared.handleButtonEvent(upEvent, cgEvent: makeCGMouseEvent()) + + XCTAssertTrue(stopCalled, "Motion tap should stop when gesture is cancelled") + } + + // MARK: - Codable Tests + + func testGestureBinding_codable_roundTrip() throws { + let binding = makeGestureBinding(scrollUpAction: "missionControl", scrollDownAction: "appExpose") + let data = try JSONEncoder().encode(binding) + let decoded = try JSONDecoder().decode(GestureBinding.self, from: data) + + XCTAssertEqual(binding.id, decoded.id) + XCTAssertEqual(binding.upAction, decoded.upAction) + XCTAssertEqual(binding.downAction, decoded.downAction) + XCTAssertEqual(binding.leftAction, decoded.leftAction) + XCTAssertEqual(binding.rightAction, decoded.rightAction) + XCTAssertEqual(binding.threshold, decoded.threshold) + XCTAssertEqual(binding.scrollUpAction, decoded.scrollUpAction) + XCTAssertEqual(binding.scrollDownAction, decoded.scrollDownAction) + XCTAssertEqual(binding.scrollThreshold, decoded.scrollThreshold) + XCTAssertEqual(binding.isEnabled, decoded.isEnabled) + } + + func testGestureBinding_codable_backwardCompatible_legacyJSON() throws { + // Simulate JSON from before scrollUpAction/scrollDownAction/scrollThreshold were added. + // Old JSON may have an `inputMode` key (now ignored) and no scroll fields. + let json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "triggerEvent": { + "type": "mouse", "code": 2, "modifiers": 0, + "displayComponents": ["🖱3"] + }, + "threshold": 30.0, + "inputMode": "scrollWheel", + "isEnabled": true, + "createdAt": 0 + } + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(GestureBinding.self, from: json) + // inputMode is silently ignored + XCTAssertEqual(decoded.threshold, 30.0, "Legacy threshold should map to movement threshold") + XCTAssertEqual(decoded.scrollThreshold, 3.0, "scrollThreshold absent → default 3.0") + XCTAssertNil(decoded.scrollUpAction, "scrollUpAction absent → nil") + XCTAssertNil(decoded.scrollDownAction, "scrollDownAction absent → nil") + } + + // MARK: - GestureBinding Threshold Tests + + func testGestureBinding_defaultMovementThreshold() { + let binding = makeGestureBinding() + XCTAssertEqual(binding.threshold, 30.0) + } + + func testGestureBinding_defaultScrollThreshold() { + let binding = makeScrollOnlyBinding() + XCTAssertEqual(binding.scrollThreshold, 3.0) + } + + func testGestureBinding_withScrollAction_returnsUpdatedCopy() { + let original = makeScrollOnlyBinding(scrollUpAction: nil) + let updated = original.withScrollAction("missionControl", for: .up) + XCTAssertEqual(updated.scrollUpAction, "missionControl") + XCTAssertNil(original.scrollUpAction, "Original should be unchanged") + } + + func testGestureBinding_hasAnyScrollAction() { + let noScroll = makeGestureBinding() + let withScroll = makeGestureBinding(scrollUpAction: "missionControl") + XCTAssertFalse(noScroll.hasAnyScrollAction) + XCTAssertTrue(withScroll.hasAnyScrollAction) + } + + func testGestureBinding_hasAnyMovementAction() { + let noMovement = makeScrollOnlyBinding() + let withMovement = makeGestureBinding() + XCTAssertFalse(noMovement.hasAnyMovementAction) + XCTAssertTrue(withMovement.hasAnyMovementAction) + } + + // MARK: - Scroll Gesture State Machine Tests + + func testHandleScrollEvent_whenIdle_notConsumed() { + let scrollEvent = makeCGScrollEvent(deltaAxis1: -5) + XCTAssertFalse(GestureProcessor.shared.handleScrollEvent(scrollEvent), + "Scroll not consumed when idle") + } + + func testHandleScrollEvent_whenPending_movementOnlyBinding_notConsumed() { + // Binding has movement actions but no scroll actions → scroll passes through + let binding = makeGestureBinding() // no scrollUpAction/scrollDownAction + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) + + XCTAssertFalse(GestureProcessor.shared.handleScrollEvent(makeCGScrollEvent(deltaAxis1: -5)), + "Scroll not consumed for movement-only binding") + } + + func testHandleScrollEvent_whenPending_scrollBinding_consumed() { + let binding = makeScrollOnlyBinding(scrollThreshold: 3.0) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) + + XCTAssertTrue(GestureProcessor.shared.handleScrollEvent(makeCGScrollEvent(deltaAxis1: -1)), + "Scroll consumed when pending with scroll actions") + } + + func testHandleScrollEvent_scrollBinding_accumulatesAndFires() { + let binding = makeScrollOnlyBinding(scrollUpAction: "missionControl", scrollThreshold: 3.0) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) + + // Three ticks upward (negative axis1) → crosses threshold of 3 + for _ in 0..<3 { + _ = GestureProcessor.shared.handleScrollEvent(makeCGScrollEvent(deltaAxis1: -1)) + } + + // State should now be .active → trigger release is consumed + let result = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .up), cgEvent: makeCGMouseEvent()) + XCTAssertEqual(result, .consumed, "After scroll gesture fires, trigger release consumed (.active state)") + } + + func testHandleScrollEvent_accumulator_isIndependentFromMotionAccumulator() { + // A binding with both movement and scroll actions. Small mouse movement (below threshold) + // followed by scroll ticks should resolve via scroll, not via accumulated motion. + let binding = makeGestureBinding( + upAction: "missionControl", threshold: 30.0, + scrollUpAction: "appExpose", scrollThreshold: 3.0 + ) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) + + // Small motion (below movement threshold) — should NOT fire movement action + let motionEvent = makeCGMotionEvent(deltaX: 0, deltaY: -5) + GestureProcessor.shared.handleMotionEvent(motionEvent) + + // Three scroll ticks upward → should fire via scroll accumulator + for _ in 0..<3 { + _ = GestureProcessor.shared.handleScrollEvent(makeCGScrollEvent(deltaAxis1: -1)) + } + + // State .active → trigger up is consumed + XCTAssertEqual( + GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .up), cgEvent: makeCGMouseEvent()), + .consumed + ) + } + + func testHandleScrollEvent_whenActive_withScrollActions_stillConsumed() { + let binding = makeScrollOnlyBinding(scrollThreshold: 3.0) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) + + for _ in 0..<3 { + _ = GestureProcessor.shared.handleScrollEvent(makeCGScrollEvent(deltaAxis1: -1)) + } + + // Additional scroll in .active state should still be consumed (prevent smooth pipeline) + XCTAssertTrue(GestureProcessor.shared.handleScrollEvent(makeCGScrollEvent(deltaAxis1: -1)), + "Scroll consumed in .active state to prevent smooth scrolling pipeline") + } + + func testMotionTap_notStartedForScrollOnlyBinding() { + let binding = makeScrollOnlyBinding() + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + var motionTapStarted = false + MouseInteractionSessionController.shared.setTestingMotionTapHooks( + start: { motionTapStarted = true }, + stop: {} + ) + + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) + + XCTAssertFalse(motionTapStarted, + "Motion tap should NOT start when binding has no movement actions") + } + + func testMotionTap_startsForBindingWithMovementActions() { + let binding = makeGestureBinding() // has movement actions + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + let expectation = expectation(description: "Motion tap starts") + MouseInteractionSessionController.shared.setTestingMotionTapHooks( + start: { expectation.fulfill() }, + stop: {} + ) + + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) + + waitForExpectations(timeout: 1.0) + } + + // MARK: - Private Helpers + + /// Create a minimal CGEvent with deltaX/deltaY field set + private func makeCGMotionEvent(deltaX: Int64, deltaY: Int64) -> CGEvent { + let event = CGEvent(source: CGEventSource(stateID: .hidSystemState))! + event.type = .mouseMoved + event.setIntegerValueField(.mouseEventDeltaX, value: deltaX) + event.setIntegerValueField(.mouseEventDeltaY, value: deltaY) + return event + } +}