From 3bfafc87c7e2c4f290a4de31f804a85db5af995e Mon Sep 17 00:00:00 2001 From: landn172 Date: Wed, 15 Apr 2026 12:02:20 +0800 Subject: [PATCH 1/5] feat(gestures): add mouse gesture support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold a configurable trigger button (e.g. middle click) and move the mouse to execute system actions by direction. Supports up/down/left/ right with independent action bindings per gesture. - GestureBinding data model: trigger + 4 directional SystemShortcut actions, threshold, enabled flag - GestureProcessor state machine: idle → pending → active, dominant-axis direction detection with 1.5× diagonal rejection, click replay on threshold miss - MouseInteractionSessionController: gestureMotionHandler callback + setGestureTracking() keep-alive flag - ButtonCore: intercept matching button events before InputProcessor; clearState on disable - Options/Constants: gesture bindings persistence via UserDefaults - PreferencesButtonsViewController: segmented Bindings/Gestures switcher reusing KeyRecorder - GestureTableCellView: programmatic cell with KeyPreview + 4 direction popups - Localizable.xcstrings: bindings, gestures, gestureNone, direction keys (11 languages) - GestureProcessorTests: state machine, direction resolution, Codable round-trip, motion tap hooks Co-Authored-By: Claude Sonnet 4.6 --- Mos.xcodeproj/project.pbxproj | 4 + Mos/ButtonCore/ButtonCore.swift | 8 + Mos/InputEvent/GestureBinding.swift | 137 ++++++++ Mos/InputEvent/GestureProcessor.swift | 270 ++++++++++++++ .../MouseInteractionSessionController.swift | 17 +- Mos/Localizable.xcstrings | 203 +++++++++++ Mos/Options/Options.swift | 42 +++ Mos/Utils/Constants.swift | 7 + .../ButtonsView/GestureTableCellView.swift | 318 +++++++++++++++++ .../PreferencesButtonsViewController.swift | 321 +++++++++++------ MosTests/GestureProcessorTests.swift | 328 ++++++++++++++++++ 11 files changed, 1546 insertions(+), 109 deletions(-) create mode 100644 Mos/InputEvent/GestureBinding.swift create mode 100644 Mos/InputEvent/GestureProcessor.swift create mode 100644 Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift create mode 100644 MosTests/GestureProcessorTests.swift 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..56dcd0d4 --- /dev/null +++ b/Mos/InputEvent/GestureBinding.swift @@ -0,0 +1,137 @@ +// +// 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 + +/// 手势绑定 - 将录制的触发按键与四个方向的动作关联 +/// 一个触发按键对应四个方向动作 (上/下/左/右), 每个方向可独立配置或留空 +struct GestureBinding: Codable, Equatable { + + // MARK: - 持久化字段 + + /// 唯一标识符 + let id: UUID + + /// 录制的触发事件 (哪个按键触发手势模式) + let triggerEvent: RecordedEvent + + /// 上方向动作名称 (SystemShortcut identifier, nil = 无动作) + var upAction: String? + + /// 下方向动作名称 + var downAction: String? + + /// 左方向动作名称 + var leftAction: String? + + /// 右方向动作名称 + var rightAction: String? + + /// 移动阈值 (像素), 超过后触发方向识别 (默认 30.0) + var threshold: Double + + /// 是否启用 + var isEnabled: Bool + + /// 创建时间 + let createdAt: Date + + // MARK: - 初始化 + + init( + id: UUID = UUID(), + triggerEvent: RecordedEvent, + upAction: String? = nil, + downAction: String? = nil, + leftAction: String? = nil, + rightAction: String? = nil, + threshold: Double = 30.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.isEnabled = isEnabled + self.createdAt = createdAt + } + + // MARK: - 方向动作访问 + + /// 获取指定方向的动作名称 + func action(for direction: GestureDirection) -> String? { + switch direction { + case .up: return upAction + case .down: return downAction + case .left: return leftAction + case .right: return rightAction + } + } + + /// 设置指定方向的动作名称 (返回更新后的副本) + 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 + } + + /// 是否有任意方向已配置动作 + var hasAnyAction: Bool { + return upAction != nil || downAction != nil || leftAction != nil || rightAction != nil + } + + // 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.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..07be3b9d --- /dev/null +++ b/Mos/InputEvent/GestureProcessor.swift @@ -0,0 +1,270 @@ +// +// 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 + + // 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() + state = .idle + return .passthrough + } else { + // Up 事件: 检查是否为触发按键的松开 + if event.type == binding.triggerEvent.type && event.code == binding.triggerEvent.code { + // 阈值未达到 → 回放原始点击 + _ = dx; _ = dy // 已经积累了一些 delta 但不足以触发 + replayOriginalClick(eventType: savedType, buttonCode: savedCode, position: cursorPos) + stopGestureTracking() + 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 调用) + func handleMotionEvent(_ event: CGEvent) { + guard case .pending(let binding, let savedType, let savedCode, let cursorPos, var dx, var dy) = state 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: - 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 + 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 + } + + state = .pending( + binding: binding, + savedCGEventType: savedCGEventType, + savedButtonCode: event.code, + cursorPosition: cursorPosition, + accumulatedDX: 0, + accumulatedDY: 0 + ) + + 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..212cf773 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,64 @@ "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" : "なし" + } + } + } + }, + "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 +3709,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 +7862,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 +9447,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/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..a1b6d32c --- /dev/null +++ b/Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift @@ -0,0 +1,318 @@ +// +// 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! + private var upPopUp: NSPopUpButton! + private var downPopUp: NSPopUpButton! + private var leftPopUp: NSPopUpButton! + private var rightPopUp: NSPopUpButton! + + // MARK: - State + + /// Per-direction current action identifier (nil = unbound) + private var currentActions: [GestureDirection: String?] = [ + .up: nil, .down: nil, .left: nil, .right: nil, + ] + + // MARK: - Callbacks + + private var onDirectionActionChanged: ((GestureDirection, SystemShortcut.Shortcut?) -> Void)? + private var onDeleteRequested: (() -> Void)? + + // MARK: - Tags + // Tag values match GestureDirection.allCases index: up=0, down=1, left=2, right=3 + + private static let tagForDirection: [GestureDirection: Int] = { + var map: [GestureDirection: Int] = [:] + for (index, direction) in GestureDirection.allCases.enumerated() { + map[direction] = index + } + return map + }() + + private func direction(forTag tag: Int) -> GestureDirection? { + return GestureDirection.allCases.indices.contains(tag) + ? GestureDirection.allCases[tag] + : nil + } + + private func popUp(for direction: GestureDirection) -> NSPopUpButton { + switch direction { + case .up: return upPopUp + case .down: return downPopUp + case .left: return leftPopUp + case .right: return rightPopUp + } + } + + // 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() { + // --- KeyPreview --- + keyPreview = KeyPreview() + keyPreview.translatesAutoresizingMaskIntoConstraints = false + addSubview(keyPreview) + + // --- Direction rows (visual order: up, left, right, down) --- + let directionStack = NSStackView() + directionStack.orientation = .vertical + directionStack.alignment = .leading + directionStack.spacing = 4 + directionStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(directionStack) + + let visualOrder: [GestureDirection] = [.up, .left, .right, .down] + for direction in visualOrder { + let row = makeDirectionRow(for: direction) + directionStack.addArrangedSubview(row) + } + + // --- Auto Layout --- + NSLayoutConstraint.activate([ + // KeyPreview: left-anchored, vertically centered + keyPreview.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + keyPreview.centerYAnchor.constraint(equalTo: centerYAnchor), + + // Direction stack: to the right of center (generous left margin), + // vertically centered + directionStack.leadingAnchor.constraint(equalTo: centerXAnchor, constant: -20), + directionStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), + directionStack.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + /// Creates a single horizontal row: [arrow label] [popup button] + private func makeDirectionRow(for direction: GestureDirection) -> NSView { + let row = NSView() + row.translatesAutoresizingMaskIntoConstraints = false + + // Arrow label + let label = NSTextField(labelWithString: direction.arrowSymbol) + label.font = NSFont.systemFont(ofSize: 13) + label.alignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + row.addSubview(label) + + // PopUpButton + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.translatesAutoresizingMaskIntoConstraints = false + popup.tag = GestureTableCellView.tagForDirection[direction] ?? 0 + 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.widthAnchor.constraint(equalToConstant: 200), + popup.centerYAnchor.constraint(equalTo: row.centerYAnchor), + + row.heightAnchor.constraint(equalTo: popup.heightAnchor), + ]) + + // Store reference + switch direction { + case .up: upPopUp = popup + case .down: downPopUp = popup + case .left: leftPopUp = popup + case .right: rightPopUp = popup + } + + return row + } + + // MARK: - Configure + + func configure( + with binding: GestureBinding, + onDirectionActionChanged: @escaping (GestureDirection, SystemShortcut.Shortcut?) -> Void, + onDeleteRequested: @escaping () -> Void + ) { + self.onDirectionActionChanged = onDirectionActionChanged + self.onDeleteRequested = onDeleteRequested + + // Update state cache + currentActions[.up] = binding.upAction + currentActions[.down] = binding.downAction + currentActions[.left] = binding.leftAction + currentActions[.right] = binding.rightAction + + // KeyPreview + keyPreview.update(from: binding.triggerEvent.displayComponents, status: .normal) + + // Build menus for each direction + for direction in GestureDirection.allCases { + setupPopUp(popUp(for: direction), direction: direction, actionName: binding.action(for: direction)) + } + } + + // MARK: - PopUp Setup + + private func setupPopUp(_ popup: NSPopUpButton, direction: GestureDirection, actionName: String?) { + let menu = NSMenu() + menu.delegate = self + + ShortcutManager.buildShortcutMenu( + into: menu, + target: self, + action: #selector(shortcutSelected(_:)), + showLogiActions: false + ) + + disableKeyEquivalents(in: menu) + + popup.menu = menu + + // Reflect current action in the placeholder slot (index 0) + 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 shortcutSelected(_ sender: NSMenuItem) { + // Identify which direction this popup belongs to via the popup's tag + guard let popup = findPopUp(containing: sender), + let direction = direction(forTag: popup.tag) else { return } + + // Ignore custom shortcut option — gestures don't support custom key combos + if sender.representedObject as? String == "__custom__" { + return + } + + let shortcut = sender.representedObject as? SystemShortcut.Shortcut + + // Update local state + currentActions[direction] = shortcut?.identifier + + // Update placeholder display + refreshDisplay(for: popup, actionName: shortcut?.identifier) + + // Notify caller + onDirectionActionChanged?(direction, shortcut) + } + + /// Walks through the sender's menu hierarchy to find which of our popups owns it. + private func findPopUp(containing item: NSMenuItem) -> NSPopUpButton? { + let allPopUps: [NSPopUpButton] = [upPopUp, downPopUp, leftPopUp, rightPopUp] + for popup in allPopUps { + if menuContains(popup.menu, item: item) { + return popup + } + } + return 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 based on current binding state. + private func adjustMenuStructure(_ menu: NSMenu) { + guard menu.items.count >= 3 else { return } + + // Determine which direction owns this menu + let allPopUps: [NSPopUpButton] = [upPopUp, downPopUp, leftPopUp, rightPopUp] + guard let ownerPopup = allPopUps.first(where: { $0.menu === menu }), + let direction = direction(forTag: ownerPopup.tag) else { return } + + let placeholderItem = menu.items[0] + let firstSeparator = menu.items[1] + let unboundItem = menu.items[2] + + let currentActionName = currentActions[direction] ?? nil + let hasBoundAction = currentActionName != 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: "") + } + } + + /// Recursively enables key equivalents from representedObject. + 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) + } + } + } + + /// Recursively disables all key equivalents. + 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..a3a48860 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,128 @@ 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) + + NSLayoutConstraint.activate([ + segmentedControl.leadingAnchor.constraint(equalTo: tableFoot.leadingAnchor, constant: 8), + segmentedControl.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 +170,192 @@ 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() } } -/** - * 表格区域渲染及操作 - **/ +// 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, + onDirectionActionChanged: { [weak self] direction, shortcut in + self?.updateGestureBinding(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 144 + } } - // 选择变化 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..8e450d00 --- /dev/null +++ b/MosTests/GestureProcessorTests.swift @@ -0,0 +1,328 @@ +// +// 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 (默认中键触发) + 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 + ) -> 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 + ) + } + + /// 创建 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() + 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.isEnabled, decoded.isEnabled) + } + + // 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 + } +} From dcb89c8f549ec6f8d8e07272dfef571fea27ec12 Mon Sep 17 00:00:00 2001 From: landn172 Date: Wed, 15 Apr 2026 13:36:30 +0800 Subject: [PATCH 2/5] feat(gestures): add scroll-wheel gesture combos and fix gesture deletion - Add GestureInputMode enum (.mouseMovement / .scrollWheel) to GestureBinding with backward-compatible Codable (old JSON without key defaults to .mouseMovement) - Add handleScrollEvent() to GestureProcessor: consumes scroll events in both .pending (accumulate delta, resolve direction) and .active (prevent smooth-scrolling pipeline leak) states - Guard handleMotionEvent and startGestureTracking to only run for .mouseMovement bindings - Hook GestureProcessor.handleScrollEvent into ScrollCore.scrollEventCallBack before trackpad check - Fix footer layout: deactivate conflicting storyboard constraints and repin +/- buttons to trailing edge so segmented control doesn't overlap them - Add right-click context menu delete to GestureTableCellView - Add input mode segmented control (Movement / Scroll) to GestureTableCellView with onInputModeChanged callback - Wire updateGestureInputMode() in PreferencesButtonsViewController - Add gestureMouseMovement, gestureScrollWheel, delete localization keys (en/zh-Hans/zh-Hant/ja) - Add 11 new unit tests covering scroll gesture state machine, backward-compatible Codable, and active-state scroll leak prevention Co-Authored-By: Claude Sonnet 4.6 --- Mos/InputEvent/GestureBinding.swift | 53 +++++- Mos/InputEvent/GestureProcessor.swift | 54 +++++- Mos/Localizable.xcstrings | 87 +++++++++ Mos/ScrollCore/ScrollCore.swift | 4 + .../ButtonsView/GestureTableCellView.swift | 52 +++++- .../PreferencesButtonsViewController.swift | 31 ++++ MosTests/GestureProcessorTests.swift | 165 +++++++++++++++++- 7 files changed, 436 insertions(+), 10 deletions(-) diff --git a/Mos/InputEvent/GestureBinding.swift b/Mos/InputEvent/GestureBinding.swift index 56dcd0d4..77b5ee55 100644 --- a/Mos/InputEvent/GestureBinding.swift +++ b/Mos/InputEvent/GestureBinding.swift @@ -8,6 +8,14 @@ import Cocoa +// MARK: - GestureInputMode + +/// 手势输入模式: 鼠标移动 或 滚轮滚动 +enum GestureInputMode: String, Codable, CaseIterable { + case mouseMovement = "mouseMovement" // 移动鼠标触发方向 (默认) + case scrollWheel = "scrollWheel" // 滚轮滚动触发方向 +} + // MARK: - GestureDirection /// 手势方向 @@ -59,9 +67,14 @@ struct GestureBinding: Codable, Equatable { /// 右方向动作名称 var rightAction: String? - /// 移动阈值 (像素), 超过后触发方向识别 (默认 30.0) + /// 移动阈值, 超过后触发方向识别 + /// mouseMovement 模式: 像素距离 (默认 30.0) + /// scrollWheel 模式: 滚轮行数 (默认 3.0) var threshold: Double + /// 手势输入模式 (默认 mouseMovement, 向后兼容) + var inputMode: GestureInputMode + /// 是否启用 var isEnabled: Bool @@ -77,7 +90,8 @@ struct GestureBinding: Codable, Equatable { downAction: String? = nil, leftAction: String? = nil, rightAction: String? = nil, - threshold: Double = 30.0, + inputMode: GestureInputMode = .mouseMovement, + threshold: Double? = nil, isEnabled: Bool = true, createdAt: Date = Date() ) { @@ -87,11 +101,35 @@ struct GestureBinding: Codable, Equatable { self.downAction = downAction self.leftAction = leftAction self.rightAction = rightAction - self.threshold = threshold + self.inputMode = inputMode + // Default threshold depends on input mode + self.threshold = threshold ?? (inputMode == .scrollWheel ? 3.0 : 30.0) self.isEnabled = isEnabled self.createdAt = createdAt } + // MARK: - Codable (backward-compatible) + + enum CodingKeys: String, CodingKey { + case id, triggerEvent, upAction, downAction, leftAction, rightAction + case threshold, inputMode, isEnabled, createdAt + } + + 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) + 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) + // inputMode defaults to .mouseMovement for bindings saved before this field existed + inputMode = (try? c.decodeIfPresent(GestureInputMode.self, forKey: .inputMode)) ?? .mouseMovement + threshold = try c.decode(Double.self, forKey: .threshold) + isEnabled = try c.decode(Bool.self, forKey: .isEnabled) + createdAt = try c.decode(Date.self, forKey: .createdAt) + } + // MARK: - 方向动作访问 /// 获取指定方向的动作名称 @@ -116,6 +154,14 @@ struct GestureBinding: Codable, Equatable { return copy } + /// 设置输入模式 (返回更新后的副本, 并重置阈值为模式默认值) + func withInputMode(_ mode: GestureInputMode) -> GestureBinding { + var copy = self + copy.inputMode = mode + copy.threshold = (mode == .scrollWheel) ? 3.0 : 30.0 + return copy + } + /// 是否有任意方向已配置动作 var hasAnyAction: Bool { return upAction != nil || downAction != nil || leftAction != nil || rightAction != nil @@ -131,6 +177,7 @@ struct GestureBinding: Codable, Equatable { lhs.leftAction == rhs.leftAction && lhs.rightAction == rhs.rightAction && lhs.threshold == rhs.threshold && + lhs.inputMode == rhs.inputMode && lhs.isEnabled == rhs.isEnabled && lhs.createdAt == rhs.createdAt } diff --git a/Mos/InputEvent/GestureProcessor.swift b/Mos/InputEvent/GestureProcessor.swift index 07be3b9d..a14be709 100644 --- a/Mos/InputEvent/GestureProcessor.swift +++ b/Mos/InputEvent/GestureProcessor.swift @@ -103,10 +103,13 @@ class GestureProcessor { // MARK: - Motion Event Handling /// 处理鼠标运动事件 (由 MouseInteractionSessionController 的 gestureMotionHandler 调用) + /// 仅处理 mouseMovement 模式的手势 func handleMotionEvent(_ event: CGEvent) { guard case .pending(let binding, let savedType, let savedCode, let cursorPos, var dx, var dy) = state else { return } + // 滚轮模式手势不通过 motion 事件触发 + guard binding.inputMode == .mouseMovement else { return } // 累积 delta let deltaX = Double(event.getIntegerValueField(.mouseEventDeltaX)) @@ -134,6 +137,52 @@ class GestureProcessor { } } + // MARK: - Scroll Event Handling + + /// 处理滚轮事件 (由 ScrollCore.scrollEventCallBack 调用) + /// - pending + scrollWheel: 积累 delta, 识别方向, 消费事件 + /// - active + scrollWheel: 持续消费, 阻止后续滚轮进入平滑管线 + /// - Returns: true 表示事件已被手势系统消费 (ScrollCore 应 return nil) + func handleScrollEvent(_ event: CGEvent) -> Bool { + switch state { + case .pending(let binding, let savedType, let savedCode, let cursorPos, var dx, var dy): + guard binding.inputMode == .scrollWheel else { return false } + + // 滚轮 delta: axis1 = 垂直 (向上为负), axis2 = 水平 (向右为正) + let deltaY = Double(event.getIntegerValueField(.scrollWheelEventDeltaAxis1)) + let deltaX = Double(event.getIntegerValueField(.scrollWheelEventDeltaAxis2)) + 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) + } + + return true + + case .active(let binding): + // 触发键仍持按中: 继续屏蔽滚轮事件, 防止进入平滑管线 + return binding.inputMode == .scrollWheel + + case .idle: + return false + } + } + // MARK: - Direction Resolution /// 判断主导方向 @@ -200,7 +249,10 @@ class GestureProcessor { accumulatedDY: 0 ) - startGestureTracking() + // 滚轮模式不需要 motion tap; 鼠标移动模式才启动 motion 追踪 + if binding.inputMode == .mouseMovement { + startGestureTracking() + } return .consumed } diff --git a/Mos/Localizable.xcstrings b/Mos/Localizable.xcstrings index 212cf773..6164da94 100755 --- a/Mos/Localizable.xcstrings +++ b/Mos/Localizable.xcstrings @@ -3318,6 +3318,93 @@ } } }, + "gestureMouseMovement" : { + "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" : "移動" + } + } + } + }, + "gestureScrollWheel" : { + "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" : { 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/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift b/Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift index a1b6d32c..6d8b2456 100644 --- a/Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift +++ b/Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift @@ -13,6 +13,7 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { // MARK: - UI Components private var keyPreview: KeyPreview! + private var inputModeControl: NSSegmentedControl! private var upPopUp: NSPopUpButton! private var downPopUp: NSPopUpButton! private var leftPopUp: NSPopUpButton! @@ -28,6 +29,7 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { // MARK: - Callbacks private var onDirectionActionChanged: ((GestureDirection, SystemShortcut.Shortcut?) -> Void)? + private var onInputModeChanged: ((GestureInputMode) -> Void)? private var onDeleteRequested: (() -> Void)? // MARK: - Tags @@ -70,11 +72,36 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { // 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) + // --- Input mode selector --- + inputModeControl = NSSegmentedControl( + labels: [ + NSLocalizedString("gestureMouseMovement", comment: ""), + NSLocalizedString("gestureScrollWheel", comment: ""), + ], + trackingMode: .selectOne, + target: self, + action: #selector(inputModeChanged(_:)) + ) + inputModeControl.selectedSegment = 0 + inputModeControl.translatesAutoresizingMaskIntoConstraints = false + addSubview(inputModeControl) + // --- Direction rows (visual order: up, left, right, down) --- let directionStack = NSStackView() directionStack.orientation = .vertical @@ -91,12 +118,15 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { // --- Auto Layout --- NSLayoutConstraint.activate([ - // KeyPreview: left-anchored, vertically centered + // KeyPreview: left-anchored, upper half keyPreview.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), - keyPreview.centerYAnchor.constraint(equalTo: centerYAnchor), + keyPreview.topAnchor.constraint(equalTo: topAnchor, constant: 12), + + // Input mode control: below keyPreview, left-aligned with it + inputModeControl.leadingAnchor.constraint(equalTo: keyPreview.leadingAnchor), + inputModeControl.topAnchor.constraint(equalTo: keyPreview.bottomAnchor, constant: 8), - // Direction stack: to the right of center (generous left margin), - // vertically centered + // Direction stack: to the right of center, vertically centered directionStack.leadingAnchor.constraint(equalTo: centerXAnchor, constant: -20), directionStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), directionStack.centerYAnchor.constraint(equalTo: centerYAnchor), @@ -150,9 +180,11 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { func configure( with binding: GestureBinding, onDirectionActionChanged: @escaping (GestureDirection, SystemShortcut.Shortcut?) -> Void, + onInputModeChanged: @escaping (GestureInputMode) -> Void, onDeleteRequested: @escaping () -> Void ) { self.onDirectionActionChanged = onDirectionActionChanged + self.onInputModeChanged = onInputModeChanged self.onDeleteRequested = onDeleteRequested // Update state cache @@ -161,6 +193,9 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { currentActions[.left] = binding.leftAction currentActions[.right] = binding.rightAction + // Input mode selector + inputModeControl.selectedSegment = binding.inputMode == .scrollWheel ? 1 : 0 + // KeyPreview keyPreview.update(from: binding.triggerEvent.displayComponents, status: .normal) @@ -230,6 +265,15 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { onDirectionActionChanged?(direction, shortcut) } + @objc private func inputModeChanged(_ sender: NSSegmentedControl) { + let mode: GestureInputMode = sender.selectedSegment == 1 ? .scrollWheel : .mouseMovement + onInputModeChanged?(mode) + } + + @objc private func deleteGesture(_ sender: NSMenuItem) { + onDeleteRequested?() + } + /// Walks through the sender's menu hierarchy to find which of our popups owns it. private func findPopUp(containing item: NSMenuItem) -> NSPopUpButton? { let allPopUps: [NSPopUpButton] = [upPopUp, downPopUp, leftPopUp, rightPopUp] diff --git a/Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift b/Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift index a3a48860..ed094e3f 100644 --- a/Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift +++ b/Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift @@ -77,9 +77,31 @@ class PreferencesButtonsViewController: NSViewController { 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), ]) } @@ -226,6 +248,12 @@ extension PreferencesButtonsViewController { gestureBindings[index] = gestureBindings[index].withAction(shortcut?.identifier, for: direction) syncGesturesWithOptions() } + + func updateGestureInputMode(id: UUID, mode: GestureInputMode) { + guard let index = gestureBindings.firstIndex(where: { $0.id == id }) else { return } + gestureBindings[index] = gestureBindings[index].withInputMode(mode) + syncGesturesWithOptions() + } } // MARK: - Table View Delegate & Data Source @@ -295,6 +323,9 @@ extension PreferencesButtonsViewController: NSTableViewDelegate, NSTableViewData onDirectionActionChanged: { [weak self] direction, shortcut in self?.updateGestureBinding(id: binding.id, direction: direction, shortcut: shortcut) }, + onInputModeChanged: { [weak self] mode in + self?.updateGestureInputMode(id: binding.id, mode: mode) + }, onDeleteRequested: { [weak self] in self?.removeGestureBinding(id: binding.id) } diff --git a/MosTests/GestureProcessorTests.swift b/MosTests/GestureProcessorTests.swift index 8e450d00..71b3a7ae 100644 --- a/MosTests/GestureProcessorTests.swift +++ b/MosTests/GestureProcessorTests.swift @@ -13,14 +13,15 @@ final class GestureProcessorTests: XCTestCase { // MARK: - Helpers - /// 创建用于测试的 GestureBinding (默认中键触发) + /// 创建用于测试的 GestureBinding (默认中键触发, mouseMovement 模式) 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 + inputMode: GestureInputMode = .mouseMovement, + threshold: Double? = nil ) -> GestureBinding { let trigger = RecordedEvent( type: .mouse, @@ -35,10 +36,20 @@ final class GestureProcessorTests: XCTestCase { downAction: downAction, leftAction: leftAction, rightAction: rightAction, + inputMode: inputMode, threshold: threshold ) } + /// 创建用于测试的滚轮手势 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( @@ -312,9 +323,159 @@ final class GestureProcessorTests: XCTestCase { XCTAssertEqual(binding.leftAction, decoded.leftAction) XCTAssertEqual(binding.rightAction, decoded.rightAction) XCTAssertEqual(binding.threshold, decoded.threshold) + XCTAssertEqual(binding.inputMode, decoded.inputMode) XCTAssertEqual(binding.isEnabled, decoded.isEnabled) } + func testGestureBinding_codable_backwardCompatible_missingInputMode() throws { + // Simulate JSON from before inputMode was added (no inputMode key) + let json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "triggerEvent": { + "type": "mouse", "code": 2, "modifiers": 0, + "displayComponents": ["🖱3"] + }, + "threshold": 30.0, + "isEnabled": true, + "createdAt": 0 + } + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(GestureBinding.self, from: json) + XCTAssertEqual(decoded.inputMode, .mouseMovement, + "Old bindings without inputMode key should default to .mouseMovement") + } + + // MARK: - GestureInputMode Tests + + func testGestureInputMode_defaultIsMouseMovement() { + let binding = makeGestureBinding() + XCTAssertEqual(binding.inputMode, .mouseMovement) + } + + func testGestureInputMode_scrollWheelDefaultThreshold() { + let binding = makeGestureBinding(inputMode: .scrollWheel) + XCTAssertEqual(binding.threshold, 3.0, + "scrollWheel mode should have smaller default threshold") + } + + func testGestureInputMode_mouseMovementDefaultThreshold() { + let binding = makeGestureBinding(inputMode: .mouseMovement) + XCTAssertEqual(binding.threshold, 30.0) + } + + func testGestureBinding_withInputMode_resetsThreshold() { + let original = makeGestureBinding(inputMode: .mouseMovement) + XCTAssertEqual(original.threshold, 30.0) + + let updated = original.withInputMode(.scrollWheel) + XCTAssertEqual(updated.inputMode, .scrollWheel) + XCTAssertEqual(updated.threshold, 3.0, "Switching to scrollWheel should reset threshold to 3.0") + XCTAssertEqual(original.inputMode, .mouseMovement, "Original should be unchanged") + } + + // MARK: - Scroll Gesture State Machine Tests + + func testHandleScrollEvent_whenIdle_notConsumed() { + // No pending gesture → scroll passes through + let scrollEvent = makeCGScrollEvent(deltaAxis1: -5) + let consumed = GestureProcessor.shared.handleScrollEvent(scrollEvent) + XCTAssertFalse(consumed, "Scroll event should not be consumed when no gesture is pending") + } + + func testHandleScrollEvent_whenPendingMouseMovementBinding_notConsumed() { + // Pending but movement-mode binding → scroll should NOT be consumed + let binding = makeGestureBinding(code: 2, inputMode: .mouseMovement) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + let downEvent = makeMouseEvent(code: 2, phase: .down) + _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + + let scrollEvent = makeCGScrollEvent(deltaAxis1: -5) + let consumed = GestureProcessor.shared.handleScrollEvent(scrollEvent) + XCTAssertFalse(consumed, "Scroll event should not be consumed for mouseMovement binding") + } + + func testHandleScrollEvent_whenPendingScrollWheelBinding_consumed() { + let binding = makeGestureBinding(code: 2, inputMode: .scrollWheel, threshold: 3.0) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + let downEvent = makeMouseEvent(code: 2, phase: .down) + _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + + // Scroll event should be consumed (gesture pending, scroll mode) + let scrollEvent = makeCGScrollEvent(deltaAxis1: -1) + let consumed = GestureProcessor.shared.handleScrollEvent(scrollEvent) + XCTAssertTrue(consumed, "Scroll event should be consumed when scroll gesture is pending") + } + + func testHandleScrollEvent_scrollWheelBinding_accumulatesAndFires() { + let binding = makeGestureBinding( + code: 2, upAction: "missionControl", inputMode: .scrollWheel, threshold: 3.0 + ) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + let downEvent = makeMouseEvent(code: 2, phase: .down) + _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + + // Three scroll ticks upward (negative axis1 = up): should reach threshold + for _ in 0..<3 { + let scrollEvent = makeCGScrollEvent(deltaAxis1: -1) + _ = GestureProcessor.shared.handleScrollEvent(scrollEvent) + } + + // After crossing threshold the state should be .active + // Verify by releasing trigger (active state → consumed) + let upEvent = makeMouseEvent(code: 2, phase: .up) + let result = GestureProcessor.shared.handleButtonEvent(upEvent, cgEvent: makeCGMouseEvent()) + XCTAssertEqual(result, .consumed, "After scroll gesture fires, trigger release should be consumed (active state)") + } + + func testHandleScrollEvent_whenActive_scrollWheelBinding_stillConsumed() { + // After direction resolves (state = .active), subsequent scroll events while + // the trigger is still held should be consumed to prevent smooth scrolling pipeline + let binding = makeGestureBinding( + code: 2, upAction: "missionControl", inputMode: .scrollWheel, threshold: 3.0 + ) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + let downEvent = makeMouseEvent(code: 2, phase: .down) + _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + + // Cross threshold → transition to .active + for _ in 0..<3 { + _ = GestureProcessor.shared.handleScrollEvent(makeCGScrollEvent(deltaAxis1: -1)) + } + + // Additional scrolls while in .active state should still be consumed + let extraScroll = makeCGScrollEvent(deltaAxis1: -1) + let consumed = GestureProcessor.shared.handleScrollEvent(extraScroll) + XCTAssertTrue(consumed, + "Scroll events in .active state should be consumed to prevent unintended smooth scrolling") + } + + func testMotionTap_notStartedForScrollWheelBinding() { + let binding = makeGestureBinding(code: 2, inputMode: .scrollWheel) + Options.shared.gestures.binding = [binding] + GestureProcessor.shared.invalidateCache() + + var motionTapStarted = false + MouseInteractionSessionController.shared.setTestingMotionTapHooks( + start: { motionTapStarted = true }, + stop: {} + ) + + let downEvent = makeMouseEvent(code: 2, phase: .down) + _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + + XCTAssertFalse(motionTapStarted, + "Motion tap should NOT start for scrollWheel mode gesture (no mouse movement needed)") + } + // MARK: - Private Helpers /// Create a minimal CGEvent with deltaX/deltaY field set From b2406bb7d573c4d4c03fcb3308654aeea2c6ed52 Mon Sep 17 00:00:00 2001 From: landn172 Date: Wed, 15 Apr 2026 14:07:54 +0800 Subject: [PATCH 3/5] refactor(gestures): independent movement + scroll configs, scroll 2-direction only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Movement (↑↓←→) and Scroll (↑↓) are now separate, simultaneously active configs on the same gesture binding — remove the single-mode toggle entirely. - GestureBinding: remove GestureInputMode enum and inputMode field; add scrollUpAction/scrollDownAction/scrollThreshold fields; manual encode(to:) skips legacy inputMode key; backward-compatible decoder ignores old inputMode - GestureProcessor: independent pendingScrollDY accumulator (separate from motion DX/DY); handleScrollEvent uses scrollAction(for:) and scrollThreshold; handleMotionEvent guards on hasAnyMovementAction; motion tap only started when binding has movement actions; pendingScrollDY reset on all pending→idle transitions - GestureTableCellView: remove inputMode segmented control; show two labeled sections side-by-side (Movement 4-dir, Scroll 2-dir); separate popup/action selectors and callbacks for each section - PreferencesButtonsViewController: replace updateGestureInputMode with updateGestureScrollAction; wire onScrollActionChanged callback; row height 150 - Localizable.xcstrings: rename gestureMouseMovement→gestureMovement, gestureScrollWheel→gestureScroll (section header labels) - GestureProcessorTests: update helpers, replace inputMode tests with threshold/scroll-action tests, add accumulator-independence test Co-Authored-By: Claude Sonnet 4.6 --- Mos/InputEvent/GestureBinding.swift | 202 ++++++----- Mos/InputEvent/GestureProcessor.swift | 58 ++- Mos/Localizable.xcstrings | 4 +- .../ButtonsView/GestureTableCellView.swift | 340 +++++++++++------- .../PreferencesButtonsViewController.swift | 12 +- MosTests/GestureProcessorTests.swift | 230 +++++++----- 6 files changed, 512 insertions(+), 334 deletions(-) diff --git a/Mos/InputEvent/GestureBinding.swift b/Mos/InputEvent/GestureBinding.swift index 77b5ee55..2e67526d 100644 --- a/Mos/InputEvent/GestureBinding.swift +++ b/Mos/InputEvent/GestureBinding.swift @@ -8,14 +8,6 @@ import Cocoa -// MARK: - GestureInputMode - -/// 手势输入模式: 鼠标移动 或 滚轮滚动 -enum GestureInputMode: String, Codable, CaseIterable { - case mouseMovement = "mouseMovement" // 移动鼠标触发方向 (默认) - case scrollWheel = "scrollWheel" // 滚轮滚动触发方向 -} - // MARK: - GestureDirection /// 手势方向 @@ -43,44 +35,34 @@ enum GestureDirection: String, Codable, CaseIterable { // MARK: - GestureBinding -/// 手势绑定 - 将录制的触发按键与四个方向的动作关联 -/// 一个触发按键对应四个方向动作 (上/下/左/右), 每个方向可独立配置或留空 +/// 手势绑定 - 将录制的触发按键与动作关联 +/// Movement (鼠标移动): 4 方向 (↑↓←→), 阈值默认 30px +/// Scroll (滚轮滚动): 2 方向 (↑↓), 阈值默认 3 tick +/// 两种输入模式相互独立, 可同时配置 struct GestureBinding: Codable, Equatable { // MARK: - 持久化字段 - /// 唯一标识符 let id: UUID - - /// 录制的触发事件 (哪个按键触发手势模式) let triggerEvent: RecordedEvent + let createdAt: Date - /// 上方向动作名称 (SystemShortcut identifier, nil = 无动作) - var upAction: String? - - /// 下方向动作名称 - var downAction: String? - - /// 左方向动作名称 - var leftAction: String? - - /// 右方向动作名称 + // --- Movement 动作 (鼠标移动方向, 4 方向) --- + var upAction: String? + var downAction: String? + var leftAction: String? var rightAction: String? - - /// 移动阈值, 超过后触发方向识别 - /// mouseMovement 模式: 像素距离 (默认 30.0) - /// scrollWheel 模式: 滚轮行数 (默认 3.0) + /// 触发方向识别所需最小移动像素 var threshold: Double - /// 手势输入模式 (默认 mouseMovement, 向后兼容) - var inputMode: GestureInputMode + // --- Scroll 动作 (滚轮方向, 仅 ↑↓) --- + var scrollUpAction: String? + var scrollDownAction: String? + /// 触发方向识别所需最小滚轮 tick 数 + var scrollThreshold: Double - /// 是否启用 var isEnabled: Bool - /// 创建时间 - let createdAt: Date - // MARK: - 初始化 init( @@ -90,49 +72,82 @@ struct GestureBinding: Codable, Equatable { downAction: String? = nil, leftAction: String? = nil, rightAction: String? = nil, - inputMode: GestureInputMode = .mouseMovement, - threshold: Double? = 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.inputMode = inputMode - // Default threshold depends on input mode - self.threshold = threshold ?? (inputMode == .scrollWheel ? 3.0 : 30.0) - self.isEnabled = isEnabled - self.createdAt = createdAt + 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, upAction, downAction, leftAction, rightAction - case threshold, inputMode, isEnabled, createdAt + 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) - 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) - // inputMode defaults to .mouseMovement for bindings saved before this field existed - inputMode = (try? c.decodeIfPresent(GestureInputMode.self, forKey: .inputMode)) ?? .mouseMovement - threshold = try c.decode(Double.self, forKey: .threshold) - isEnabled = try c.decode(Bool.self, forKey: .isEnabled) - createdAt = try c.decode(Date.self, forKey: .createdAt) + 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: - 方向动作访问 + // MARK: - 方向动作访问 (Movement) - /// 获取指定方向的动作名称 + /// 获取指定 Movement 方向的动作名称 func action(for direction: GestureDirection) -> String? { switch direction { case .up: return upAction @@ -142,43 +157,68 @@ struct GestureBinding: Codable, Equatable { } } - /// 设置指定方向的动作名称 (返回更新后的副本) + /// 设置指定 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 .up: copy.upAction = action + case .down: copy.downAction = action + case .left: copy.leftAction = action case .right: copy.rightAction = action } return copy } - /// 设置输入模式 (返回更新后的副本, 并重置阈值为模式默认值) - func withInputMode(_ mode: GestureInputMode) -> GestureBinding { + // 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 - copy.inputMode = mode - copy.threshold = (mode == .scrollWheel) ? 3.0 : 30.0 + switch direction { + case .up: copy.scrollUpAction = action + case .down: copy.scrollDownAction = action + default: break + } return copy } - /// 是否有任意方向已配置动作 - var hasAnyAction: Bool { + // 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.inputMode == rhs.inputMode && - lhs.isEnabled == rhs.isEnabled && - lhs.createdAt == rhs.createdAt + 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 index a14be709..0926541c 100644 --- a/Mos/InputEvent/GestureProcessor.swift +++ b/Mos/InputEvent/GestureProcessor.swift @@ -32,6 +32,10 @@ class GestureProcessor { private var state: State = .idle + /// 独立的滚轮累积值 — 与 motion 累积 (state 中的 DX/DY) 完全分离 + /// 进入 pending 时重置, 离开 pending (无论是激活还是取消) 时重置 + private var pendingScrollDY: Double = 0 + // MARK: - Binding Cache /// 缓存的手势绑定列表 (按触发优先级排序: modifier 数量多的优先) @@ -71,15 +75,17 @@ class GestureProcessor { // 另一个按键按下时取消手势, 回放原始点击 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 // 已经积累了一些 delta 但不足以触发 + _ = dx; _ = dy replayOriginalClick(eventType: savedType, buttonCode: savedCode, position: cursorPos) stopGestureTracking() + pendingScrollDY = 0 state = .idle return .consumed } @@ -103,13 +109,12 @@ class GestureProcessor { // MARK: - Motion Event Handling /// 处理鼠标运动事件 (由 MouseInteractionSessionController 的 gestureMotionHandler 调用) - /// 仅处理 mouseMovement 模式的手势 + /// 仅当绑定有 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 } - // 滚轮模式手势不通过 motion 事件触发 - guard binding.inputMode == .mouseMovement else { return } + guard binding.hasAnyMovementAction else { return } // 累积 delta let deltaX = Double(event.getIntegerValueField(.mouseEventDeltaX)) @@ -140,43 +145,32 @@ class GestureProcessor { // MARK: - Scroll Event Handling /// 处理滚轮事件 (由 ScrollCore.scrollEventCallBack 调用) - /// - pending + scrollWheel: 积累 delta, 识别方向, 消费事件 - /// - active + scrollWheel: 持续消费, 阻止后续滚轮进入平滑管线 + /// Scroll 动作与 Movement 动作完全独立, 使用独立的 pendingScrollDY 累积器 + /// - pending: 仅垂直方向 (↑↓) — 积累 tick 数, 识别方向, 消费事件 + /// - active: 持续消费, 阻止后续滚轮进入平滑管线 /// - Returns: true 表示事件已被手势系统消费 (ScrollCore 应 return nil) func handleScrollEvent(_ event: CGEvent) -> Bool { switch state { - case .pending(let binding, let savedType, let savedCode, let cursorPos, var dx, var dy): - guard binding.inputMode == .scrollWheel else { return false } + case .pending(let binding, _, _, _, _, _): + guard binding.hasAnyScrollAction else { return false } - // 滚轮 delta: axis1 = 垂直 (向上为负), axis2 = 水平 (向右为正) + // 滚轮手势只识别垂直方向 (axis1: 向上为负, 向下为正) let deltaY = Double(event.getIntegerValueField(.scrollWheelEventDeltaAxis1)) - let deltaX = Double(event.getIntegerValueField(.scrollWheelEventDeltaAxis2)) - 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 { + pendingScrollDY += deltaY + + if abs(pendingScrollDY) >= binding.scrollThreshold { + let direction: GestureDirection = pendingScrollDY < 0 ? .up : .down + if let actionName = binding.scrollAction(for: direction), !actionName.isEmpty { ShortcutExecutor.shared.execute(named: actionName) } + pendingScrollDY = 0 state = .active(binding: binding) } - return true case .active(let binding): - // 触发键仍持按中: 继续屏蔽滚轮事件, 防止进入平滑管线 - return binding.inputMode == .scrollWheel + // 触发键仍持按中: 继续屏蔽滚轮, 防止意外进入平滑管线 + return binding.hasAnyScrollAction case .idle: return false @@ -212,6 +206,7 @@ class GestureProcessor { /// 清空所有手势状态 (ButtonCore disable 时调用) func clearState() { state = .idle + pendingScrollDY = 0 stopGestureTracking() } @@ -240,6 +235,7 @@ class GestureProcessor { savedCGEventType = .keyDown } + pendingScrollDY = 0 state = .pending( binding: binding, savedCGEventType: savedCGEventType, @@ -249,8 +245,8 @@ class GestureProcessor { accumulatedDY: 0 ) - // 滚轮模式不需要 motion tap; 鼠标移动模式才启动 motion 追踪 - if binding.inputMode == .mouseMovement { + // motion tap 只在有 movement 动作时才需要 + if binding.hasAnyMovementAction { startGestureTracking() } return .consumed diff --git a/Mos/Localizable.xcstrings b/Mos/Localizable.xcstrings index 6164da94..f0d29dea 100755 --- a/Mos/Localizable.xcstrings +++ b/Mos/Localizable.xcstrings @@ -3318,7 +3318,7 @@ } } }, - "gestureMouseMovement" : { + "gestureMovement" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -3347,7 +3347,7 @@ } } }, - "gestureScrollWheel" : { + "gestureScroll" : { "extractionState" : "manual", "localizations" : { "en" : { diff --git a/Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift b/Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift index 6d8b2456..94c8f84e 100644 --- a/Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift +++ b/Mos/Windows/PreferencesWindow/ButtonsView/GestureTableCellView.swift @@ -13,27 +13,38 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { // MARK: - UI Components private var keyPreview: KeyPreview! - private var inputModeControl: NSSegmentedControl! - private var upPopUp: NSPopUpButton! - private var downPopUp: NSPopUpButton! - private var leftPopUp: NSPopUpButton! + + // 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 action identifier (nil = unbound) + /// 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 onDirectionActionChanged: ((GestureDirection, SystemShortcut.Shortcut?) -> Void)? - private var onInputModeChanged: ((GestureInputMode) -> Void)? + private var onMovementActionChanged: ((GestureDirection, SystemShortcut.Shortcut?) -> Void)? + private var onScrollActionChanged: ((GestureDirection, SystemShortcut.Shortcut?) -> Void)? private var onDeleteRequested: (() -> Void)? // MARK: - Tags - // Tag values match GestureDirection.allCases index: up=0, down=1, left=2, right=3 + // 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] = [:] @@ -43,13 +54,16 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { return map }() - private func direction(forTag tag: Int) -> GestureDirection? { + 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 popUp(for direction: GestureDirection) -> NSPopUpButton { + private func movementPopUp(for direction: GestureDirection) -> NSPopUpButton { switch direction { case .up: return upPopUp case .down: return downPopUp @@ -58,6 +72,14 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { } } + 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) { @@ -88,67 +110,134 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { keyPreview.translatesAutoresizingMaskIntoConstraints = false addSubview(keyPreview) - // --- Input mode selector --- - inputModeControl = NSSegmentedControl( - labels: [ - NSLocalizedString("gestureMouseMovement", comment: ""), - NSLocalizedString("gestureScrollWheel", comment: ""), - ], - trackingMode: .selectOne, - target: self, - action: #selector(inputModeChanged(_:)) + // --- Movement section --- + let movementSection = makeSectionView( + title: NSLocalizedString("gestureMovement", comment: ""), + content: makeMovementStack() ) - inputModeControl.selectedSegment = 0 - inputModeControl.translatesAutoresizingMaskIntoConstraints = false - addSubview(inputModeControl) - - // --- Direction rows (visual order: up, left, right, down) --- - let directionStack = NSStackView() - directionStack.orientation = .vertical - directionStack.alignment = .leading - directionStack.spacing = 4 - directionStack.translatesAutoresizingMaskIntoConstraints = false - addSubview(directionStack) + movementSection.translatesAutoresizingMaskIntoConstraints = false + addSubview(movementSection) - let visualOrder: [GestureDirection] = [.up, .left, .right, .down] - for direction in visualOrder { - let row = makeDirectionRow(for: direction) - directionStack.addArrangedSubview(row) - } + // --- Scroll section --- + let scrollSection = makeSectionView( + title: NSLocalizedString("gestureScroll", comment: ""), + content: makeScrollStack() + ) + scrollSection.translatesAutoresizingMaskIntoConstraints = false + addSubview(scrollSection) // --- Auto Layout --- NSLayoutConstraint.activate([ - // KeyPreview: left-anchored, upper half + // KeyPreview: left-anchored, vertically centered keyPreview.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), - keyPreview.topAnchor.constraint(equalTo: topAnchor, constant: 12), + 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 - // Input mode control: below keyPreview, left-aligned with it - inputModeControl.leadingAnchor.constraint(equalTo: keyPreview.leadingAnchor), - inputModeControl.topAnchor.constraint(equalTo: keyPreview.bottomAnchor, constant: 8), + 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), - // Direction stack: to the right of center, vertically centered - directionStack.leadingAnchor.constraint(equalTo: centerXAnchor, constant: -20), - directionStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), - directionStack.centerYAnchor.constraint(equalTo: centerYAnchor), + 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) } - /// Creates a single horizontal row: [arrow label] [popup button] - private func makeDirectionRow(for direction: GestureDirection) -> NSView { + /// Generic [arrow label] [popup] row. + private func makeDirectionRow(arrowSymbol: String, popup: NSPopUpButton) -> NSView { let row = NSView() row.translatesAutoresizingMaskIntoConstraints = false - // Arrow label - let label = NSTextField(labelWithString: direction.arrowSymbol) + let label = NSTextField(labelWithString: arrowSymbol) label.font = NSFont.systemFont(ofSize: 13) label.alignment = .center label.translatesAutoresizingMaskIntoConstraints = false row.addSubview(label) - - // PopUpButton - let popup = NSPopUpButton(frame: .zero, pullsDown: false) - popup.translatesAutoresizingMaskIntoConstraints = false - popup.tag = GestureTableCellView.tagForDirection[direction] ?? 0 row.addSubview(popup) NSLayoutConstraint.activate([ @@ -158,20 +247,10 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { popup.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 4), popup.trailingAnchor.constraint(equalTo: row.trailingAnchor), - popup.widthAnchor.constraint(equalToConstant: 200), popup.centerYAnchor.constraint(equalTo: row.centerYAnchor), row.heightAnchor.constraint(equalTo: popup.heightAnchor), ]) - - // Store reference - switch direction { - case .up: upPopUp = popup - case .down: downPopUp = popup - case .left: leftPopUp = popup - case .right: rightPopUp = popup - } - return row } @@ -179,50 +258,57 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { func configure( with binding: GestureBinding, - onDirectionActionChanged: @escaping (GestureDirection, SystemShortcut.Shortcut?) -> Void, - onInputModeChanged: @escaping (GestureInputMode) -> Void, + onMovementActionChanged: @escaping (GestureDirection, SystemShortcut.Shortcut?) -> Void, + onScrollActionChanged: @escaping (GestureDirection, SystemShortcut.Shortcut?) -> Void, onDeleteRequested: @escaping () -> Void ) { - self.onDirectionActionChanged = onDirectionActionChanged - self.onInputModeChanged = onInputModeChanged - self.onDeleteRequested = onDeleteRequested + self.onMovementActionChanged = onMovementActionChanged + self.onScrollActionChanged = onScrollActionChanged + self.onDeleteRequested = onDeleteRequested - // Update state cache + // Update movement state cache currentActions[.up] = binding.upAction currentActions[.down] = binding.downAction currentActions[.left] = binding.leftAction currentActions[.right] = binding.rightAction - // Input mode selector - inputModeControl.selectedSegment = binding.inputMode == .scrollWheel ? 1 : 0 + // Update scroll state cache + currentScrollActions[.up] = binding.scrollUpAction + currentScrollActions[.down] = binding.scrollDownAction // KeyPreview keyPreview.update(from: binding.triggerEvent.displayComponents, status: .normal) - // Build menus for each direction + // Build movement menus for direction in GestureDirection.allCases { - setupPopUp(popUp(for: direction), direction: direction, actionName: binding.action(for: direction)) + 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 setupPopUp(_ popup: NSPopUpButton, direction: GestureDirection, actionName: String?) { + private func setupMovementPopUp(_ popup: NSPopUpButton, direction: GestureDirection, actionName: String?) { let menu = NSMenu() menu.delegate = self - - ShortcutManager.buildShortcutMenu( - into: menu, - target: self, - action: #selector(shortcutSelected(_:)), - showLogiActions: false - ) - + ShortcutManager.buildShortcutMenu(into: menu, target: self, action: #selector(movementShortcutSelected(_:)), showLogiActions: false) disableKeyEquivalents(in: menu) - popup.menu = menu + refreshDisplay(for: popup, actionName: actionName) + } - // Reflect current action in the placeholder slot (index 0) + 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) } @@ -243,46 +329,43 @@ class GestureTableCellView: NSTableCellView, NSMenuDelegate { // MARK: - Actions - @objc private func shortcutSelected(_ sender: NSMenuItem) { - // Identify which direction this popup belongs to via the popup's tag - guard let popup = findPopUp(containing: sender), - let direction = direction(forTag: popup.tag) else { return } + @objc private func movementShortcutSelected(_ sender: NSMenuItem) { + guard let popup = findPopUpInMovement(containing: sender), + let direction = direction(forMovementTag: popup.tag) else { return } - // Ignore custom shortcut option — gestures don't support custom key combos - if sender.representedObject as? String == "__custom__" { - return - } + if sender.representedObject as? String == "__custom__" { return } let shortcut = sender.representedObject as? SystemShortcut.Shortcut - - // Update local state currentActions[direction] = shortcut?.identifier - - // Update placeholder display refreshDisplay(for: popup, actionName: shortcut?.identifier) - - // Notify caller - onDirectionActionChanged?(direction, shortcut) + onMovementActionChanged?(direction, shortcut) } - @objc private func inputModeChanged(_ sender: NSSegmentedControl) { - let mode: GestureInputMode = sender.selectedSegment == 1 ? .scrollWheel : .mouseMovement - onInputModeChanged?(mode) + @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?() } - /// Walks through the sender's menu hierarchy to find which of our popups owns it. - private func findPopUp(containing item: NSMenuItem) -> NSPopUpButton? { + // MARK: - PopUp Lookup + + private func findPopUpInMovement(containing item: NSMenuItem) -> NSPopUpButton? { let allPopUps: [NSPopUpButton] = [upPopUp, downPopUp, leftPopUp, rightPopUp] - for popup in allPopUps { - if menuContains(popup.menu, item: item) { - return popup - } - } - return nil + 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 { @@ -308,21 +391,30 @@ extension GestureTableCellView { disableKeyEquivalents(in: menu) } - /// Dynamically adjusts placeholder / unbound item visibility based on current binding state. + /// Dynamically adjusts placeholder / unbound item visibility. private func adjustMenuStructure(_ menu: NSMenu) { guard menu.items.count >= 3 else { return } - // Determine which direction owns this menu - let allPopUps: [NSPopUpButton] = [upPopUp, downPopUp, leftPopUp, rightPopUp] - guard let ownerPopup = allPopUps.first(where: { $0.menu === menu }), - let direction = direction(forTag: ownerPopup.tag) 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 currentActionName = currentActions[direction] ?? nil - let hasBoundAction = currentActionName != nil + let hasBoundAction = (currentActionName ?? nil) != nil if hasBoundAction { placeholderItem.isHidden = false @@ -335,28 +427,22 @@ extension GestureTableCellView { } } - /// Recursively enables key equivalents from representedObject. 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) + item.keyEquivalent = keyEquivalent.keyEquivalent + item.keyEquivalentModifierMask = keyEquivalent.modifierMask } + if let submenu = item.submenu { enableKeyEquivalents(in: submenu) } } } - /// Recursively disables all key equivalents. private func disableKeyEquivalents(in menu: NSMenu) { for item in menu.items { item.keyEquivalent = "" item.keyEquivalentModifierMask = [] - if let submenu = item.submenu { - disableKeyEquivalents(in: submenu) - } + 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 ed094e3f..a1edf7fa 100644 --- a/Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift +++ b/Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift @@ -249,9 +249,9 @@ extension PreferencesButtonsViewController { syncGesturesWithOptions() } - func updateGestureInputMode(id: UUID, mode: GestureInputMode) { + func updateGestureScrollAction(id: UUID, direction: GestureDirection, shortcut: SystemShortcut.Shortcut?) { guard let index = gestureBindings.firstIndex(where: { $0.id == id }) else { return } - gestureBindings[index] = gestureBindings[index].withInputMode(mode) + gestureBindings[index] = gestureBindings[index].withScrollAction(shortcut?.identifier, for: direction) syncGesturesWithOptions() } } @@ -320,11 +320,11 @@ extension PreferencesButtonsViewController: NSTableViewDelegate, NSTableViewData let binding = gestureBindings[row] cell.configure( with: binding, - onDirectionActionChanged: { [weak self] direction, shortcut in + onMovementActionChanged: { [weak self] direction, shortcut in self?.updateGestureBinding(id: binding.id, direction: direction, shortcut: shortcut) }, - onInputModeChanged: { [weak self] mode in - self?.updateGestureInputMode(id: binding.id, mode: mode) + onScrollActionChanged: { [weak self] direction, shortcut in + self?.updateGestureScrollAction(id: binding.id, direction: direction, shortcut: shortcut) }, onDeleteRequested: { [weak self] in self?.removeGestureBinding(id: binding.id) @@ -340,7 +340,7 @@ extension PreferencesButtonsViewController: NSTableViewDelegate, NSTableViewData func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { switch currentMode { case .bindings: return 44 - case .gestures: return 144 + case .gestures: return 150 } } diff --git a/MosTests/GestureProcessorTests.swift b/MosTests/GestureProcessorTests.swift index 71b3a7ae..c3eba50b 100644 --- a/MosTests/GestureProcessorTests.swift +++ b/MosTests/GestureProcessorTests.swift @@ -13,15 +13,18 @@ final class GestureProcessorTests: XCTestCase { // MARK: - Helpers - /// 创建用于测试的 GestureBinding (默认中键触发, mouseMovement 模式) + /// 创建用于测试的 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", - inputMode: GestureInputMode = .mouseMovement, - threshold: Double? = nil + threshold: Double = 30.0, + scrollUpAction: String? = nil, + scrollDownAction: String? = nil, + scrollThreshold: Double = 3.0 ) -> GestureBinding { let trigger = RecordedEvent( type: .mouse, @@ -36,8 +39,26 @@ final class GestureProcessorTests: XCTestCase { downAction: downAction, leftAction: leftAction, rightAction: rightAction, - inputMode: inputMode, - threshold: threshold + 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 ) } @@ -313,22 +334,25 @@ final class GestureProcessorTests: XCTestCase { // MARK: - Codable Tests func testGestureBinding_codable_roundTrip() throws { - let binding = makeGestureBinding() + 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.inputMode, decoded.inputMode) - XCTAssertEqual(binding.isEnabled, decoded.isEnabled) - } - - func testGestureBinding_codable_backwardCompatible_missingInputMode() throws { - // Simulate JSON from before inputMode was added (no inputMode key) + 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", @@ -337,129 +361,146 @@ final class GestureProcessorTests: XCTestCase { "displayComponents": ["🖱3"] }, "threshold": 30.0, + "inputMode": "scrollWheel", "isEnabled": true, "createdAt": 0 } """.data(using: .utf8)! let decoded = try JSONDecoder().decode(GestureBinding.self, from: json) - XCTAssertEqual(decoded.inputMode, .mouseMovement, - "Old bindings without inputMode key should default to .mouseMovement") + // 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: - GestureInputMode Tests + // MARK: - GestureBinding Threshold Tests - func testGestureInputMode_defaultIsMouseMovement() { + func testGestureBinding_defaultMovementThreshold() { let binding = makeGestureBinding() - XCTAssertEqual(binding.inputMode, .mouseMovement) + XCTAssertEqual(binding.threshold, 30.0) } - func testGestureInputMode_scrollWheelDefaultThreshold() { - let binding = makeGestureBinding(inputMode: .scrollWheel) - XCTAssertEqual(binding.threshold, 3.0, - "scrollWheel mode should have smaller default threshold") + func testGestureBinding_defaultScrollThreshold() { + let binding = makeScrollOnlyBinding() + XCTAssertEqual(binding.scrollThreshold, 3.0) } - func testGestureInputMode_mouseMovementDefaultThreshold() { - let binding = makeGestureBinding(inputMode: .mouseMovement) - XCTAssertEqual(binding.threshold, 30.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_withInputMode_resetsThreshold() { - let original = makeGestureBinding(inputMode: .mouseMovement) - XCTAssertEqual(original.threshold, 30.0) + func testGestureBinding_hasAnyScrollAction() { + let noScroll = makeGestureBinding() + let withScroll = makeGestureBinding(scrollUpAction: "missionControl") + XCTAssertFalse(noScroll.hasAnyScrollAction) + XCTAssertTrue(withScroll.hasAnyScrollAction) + } - let updated = original.withInputMode(.scrollWheel) - XCTAssertEqual(updated.inputMode, .scrollWheel) - XCTAssertEqual(updated.threshold, 3.0, "Switching to scrollWheel should reset threshold to 3.0") - XCTAssertEqual(original.inputMode, .mouseMovement, "Original should be unchanged") + 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() { - // No pending gesture → scroll passes through let scrollEvent = makeCGScrollEvent(deltaAxis1: -5) - let consumed = GestureProcessor.shared.handleScrollEvent(scrollEvent) - XCTAssertFalse(consumed, "Scroll event should not be consumed when no gesture is pending") + XCTAssertFalse(GestureProcessor.shared.handleScrollEvent(scrollEvent), + "Scroll not consumed when idle") } - func testHandleScrollEvent_whenPendingMouseMovementBinding_notConsumed() { - // Pending but movement-mode binding → scroll should NOT be consumed - let binding = makeGestureBinding(code: 2, inputMode: .mouseMovement) + 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() - let downEvent = makeMouseEvent(code: 2, phase: .down) - _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) - let scrollEvent = makeCGScrollEvent(deltaAxis1: -5) - let consumed = GestureProcessor.shared.handleScrollEvent(scrollEvent) - XCTAssertFalse(consumed, "Scroll event should not be consumed for mouseMovement binding") + XCTAssertFalse(GestureProcessor.shared.handleScrollEvent(makeCGScrollEvent(deltaAxis1: -5)), + "Scroll not consumed for movement-only binding") } - func testHandleScrollEvent_whenPendingScrollWheelBinding_consumed() { - let binding = makeGestureBinding(code: 2, inputMode: .scrollWheel, threshold: 3.0) + func testHandleScrollEvent_whenPending_scrollBinding_consumed() { + let binding = makeScrollOnlyBinding(scrollThreshold: 3.0) Options.shared.gestures.binding = [binding] GestureProcessor.shared.invalidateCache() - let downEvent = makeMouseEvent(code: 2, phase: .down) - _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) - // Scroll event should be consumed (gesture pending, scroll mode) - let scrollEvent = makeCGScrollEvent(deltaAxis1: -1) - let consumed = GestureProcessor.shared.handleScrollEvent(scrollEvent) - XCTAssertTrue(consumed, "Scroll event should be consumed when scroll gesture is pending") + XCTAssertTrue(GestureProcessor.shared.handleScrollEvent(makeCGScrollEvent(deltaAxis1: -1)), + "Scroll consumed when pending with scroll actions") } - func testHandleScrollEvent_scrollWheelBinding_accumulatesAndFires() { - let binding = makeGestureBinding( - code: 2, upAction: "missionControl", inputMode: .scrollWheel, threshold: 3.0 - ) + func testHandleScrollEvent_scrollBinding_accumulatesAndFires() { + let binding = makeScrollOnlyBinding(scrollUpAction: "missionControl", scrollThreshold: 3.0) Options.shared.gestures.binding = [binding] GestureProcessor.shared.invalidateCache() - let downEvent = makeMouseEvent(code: 2, phase: .down) - _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) - // Three scroll ticks upward (negative axis1 = up): should reach threshold + // Three ticks upward (negative axis1) → crosses threshold of 3 for _ in 0..<3 { - let scrollEvent = makeCGScrollEvent(deltaAxis1: -1) - _ = GestureProcessor.shared.handleScrollEvent(scrollEvent) + _ = GestureProcessor.shared.handleScrollEvent(makeCGScrollEvent(deltaAxis1: -1)) } - // After crossing threshold the state should be .active - // Verify by releasing trigger (active state → consumed) - let upEvent = makeMouseEvent(code: 2, phase: .up) - let result = GestureProcessor.shared.handleButtonEvent(upEvent, cgEvent: makeCGMouseEvent()) - XCTAssertEqual(result, .consumed, "After scroll gesture fires, trigger release should be consumed (active state)") + // 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_whenActive_scrollWheelBinding_stillConsumed() { - // After direction resolves (state = .active), subsequent scroll events while - // the trigger is still held should be consumed to prevent smooth scrolling pipeline + 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( - code: 2, upAction: "missionControl", inputMode: .scrollWheel, threshold: 3.0 + upAction: "missionControl", threshold: 30.0, + scrollUpAction: "appExpose", scrollThreshold: 3.0 ) Options.shared.gestures.binding = [binding] GestureProcessor.shared.invalidateCache() - let downEvent = makeMouseEvent(code: 2, phase: .down) - _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) - // Cross threshold → transition to .active + // 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)) } - // Additional scrolls while in .active state should still be consumed - let extraScroll = makeCGScrollEvent(deltaAxis1: -1) - let consumed = GestureProcessor.shared.handleScrollEvent(extraScroll) - XCTAssertTrue(consumed, - "Scroll events in .active state should be consumed to prevent unintended smooth scrolling") + // State .active → trigger up is consumed + XCTAssertEqual( + GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .up), cgEvent: makeCGMouseEvent()), + .consumed + ) } - func testMotionTap_notStartedForScrollWheelBinding() { - let binding = makeGestureBinding(code: 2, inputMode: .scrollWheel) + 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() @@ -469,11 +510,26 @@ final class GestureProcessorTests: XCTestCase { stop: {} ) - let downEvent = makeMouseEvent(code: 2, phase: .down) - _ = GestureProcessor.shared.handleButtonEvent(downEvent, cgEvent: makeCGMouseEvent()) + _ = GestureProcessor.shared.handleButtonEvent(makeMouseEvent(code: 2, phase: .down), cgEvent: makeCGMouseEvent()) XCTAssertFalse(motionTapStarted, - "Motion tap should NOT start for scrollWheel mode gesture (no mouse movement needed)") + "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 From f3931e6e177210a308c236887c2831c0d9c8bcd0 Mon Sep 17 00:00:00 2001 From: landn172 Date: Wed, 15 Apr 2026 14:39:37 +0800 Subject: [PATCH 4/5] fix(gestures): fix scroll-down no-reaction and smooth-scroll conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes: 1. Silent event consumption: when scrollDownAction is nil but scrollUpAction is set, hasAnyScrollAction=true caused all scroll-down events to be consumed (returning true) without any action firing. User saw no scrolling and no action — perceived as "scroll conflict". Fix: direction-aware consume. Check binding.scrollAction(for: eventDirection) before accumulating. If nil for that direction, immediately return false (let scroll pass through normally). Reset accumulator on direction change. 2. Smooth-scroll mouse delta: reading scrollWheelEventDeltaAxis1 (integer) gives 0 for continuous/smooth scroll devices. No accumulation → threshold never reached. Fix: prefer scrollWheelEventFixedPtDeltaAxis1 (float fixedPt field, same priority logic as ScrollEvent.initEvent), fall back to integer field. Both pending and active states updated to use the same direction-aware logic. Co-Authored-By: Claude Sonnet 4.6 --- Mos/InputEvent/GestureProcessor.swift | 50 +++++++++++++++++++++------ 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/Mos/InputEvent/GestureProcessor.swift b/Mos/InputEvent/GestureProcessor.swift index 0926541c..e4bcb99c 100644 --- a/Mos/InputEvent/GestureProcessor.swift +++ b/Mos/InputEvent/GestureProcessor.swift @@ -146,31 +146,61 @@ class GestureProcessor { /// 处理滚轮事件 (由 ScrollCore.scrollEventCallBack 调用) /// Scroll 动作与 Movement 动作完全独立, 使用独立的 pendingScrollDY 累积器 - /// - pending: 仅垂直方向 (↑↓) — 积累 tick 数, 识别方向, 消费事件 - /// - active: 持续消费, 阻止后续滚轮进入平滑管线 + /// + /// 消费规则 (防止"滚动冲突"): + /// - 只消费该方向上有配置动作的滚轮事件; 无动作的方向立即放行, 正常滚动 + /// - 换向时重置累积器 + /// - 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 } - // 滚轮手势只识别垂直方向 (axis1: 向上为负, 向下为正) - let deltaY = Double(event.getIntegerValueField(.scrollWheelEventDeltaAxis1)) - pendingScrollDY += deltaY + // 使用 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 { - let direction: GestureDirection = pendingScrollDY < 0 ? .up : .down - if let actionName = binding.scrollAction(for: direction), !actionName.isEmpty { + if let actionName = binding.scrollAction(for: eventDirection), !actionName.isEmpty { ShortcutExecutor.shared.execute(named: actionName) } pendingScrollDY = 0 state = .active(binding: binding) } - return true + return true // 消费: 正在向有动作的方向积累 case .active(let binding): - // 触发键仍持按中: 继续屏蔽滚轮, 防止意外进入平滑管线 - return binding.hasAnyScrollAction + // 触发键仍持按中: 仅消费有动作的方向, 无动作方向正常滚动 + 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 + return binding.scrollAction(for: direction) != nil case .idle: return false From 1828873444bf98783a74e11c275cd2474d2c9831 Mon Sep 17 00:00:00 2001 From: landn172 Date: Wed, 15 Apr 2026 14:45:02 +0800 Subject: [PATCH 5/5] fix(gestures): allow scroll gestures to re-fire during single button hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, after a scroll gesture fired the state became .active and all subsequent scroll events in configured directions were consumed without firing. This caused a 5+ second apparent freeze: the user had to fully release the trigger button before the other direction would work again. Fix: in .active state, scroll gestures now re-fire on every threshold crossing. Hold button + scroll up → fires, scroll down → fires, scroll up → fires again. Directions with no configured action still pass through immediately. Co-Authored-By: Claude Sonnet 4.6 --- Mos/InputEvent/GestureProcessor.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Mos/InputEvent/GestureProcessor.swift b/Mos/InputEvent/GestureProcessor.swift index e4bcb99c..31ce2fff 100644 --- a/Mos/InputEvent/GestureProcessor.swift +++ b/Mos/InputEvent/GestureProcessor.swift @@ -193,14 +193,29 @@ class GestureProcessor { 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 - return binding.scrollAction(for: direction) != nil + + 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