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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Mos.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
8 changes: 8 additions & 0 deletions Mos/ButtonCore/ButtonCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -102,6 +109,7 @@ class ButtonCore {
eventInterceptor?.stop()
eventInterceptor = nil
InputProcessor.shared.clearActiveBindings()
GestureProcessor.shared.clearState()
isActive = false
}
}
Expand Down
224 changes: 224 additions & 0 deletions Mos/InputEvent/GestureBinding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//
// GestureBinding.swift
// Mos
// 鼠标手势绑定数据结构
// Created by Claude on 2026/4/15.
// Copyright © 2026 Caldis. All rights reserved.
//

import Cocoa

// MARK: - GestureDirection

/// 手势方向
enum GestureDirection: String, Codable, CaseIterable {
case up = "up"
case down = "down"
case left = "left"
case right = "right"

/// 方向箭头符号 (用于 UI 显示)
var arrowSymbol: String {
switch self {
case .up: return "↑"
case .down: return "↓"
case .left: return "←"
case .right: return "→"
}
}

/// 本地化显示名称
var localizedName: String {
return NSLocalizedString(rawValue, comment: "")
}
}

// MARK: - GestureBinding

/// 手势绑定 - 将录制的触发按键与动作关联
/// Movement (鼠标移动): 4 方向 (↑↓←→), 阈值默认 30px
/// Scroll (滚轮滚动): 2 方向 (↑↓), 阈值默认 3 tick
/// 两种输入模式相互独立, 可同时配置
struct GestureBinding: Codable, Equatable {

// MARK: - 持久化字段

let id: UUID
let triggerEvent: RecordedEvent
let createdAt: Date

// --- Movement 动作 (鼠标移动方向, 4 方向) ---
var upAction: String?
var downAction: String?
var leftAction: String?
var rightAction: String?
/// 触发方向识别所需最小移动像素
var threshold: Double

// --- Scroll 动作 (滚轮方向, 仅 ↑↓) ---
var scrollUpAction: String?
var scrollDownAction: String?
/// 触发方向识别所需最小滚轮 tick 数
var scrollThreshold: Double

var isEnabled: Bool

// MARK: - 初始化

init(
id: UUID = UUID(),
triggerEvent: RecordedEvent,
upAction: String? = nil,
downAction: String? = nil,
leftAction: String? = nil,
rightAction: String? = nil,
threshold: Double = 30.0,
scrollUpAction: String? = nil,
scrollDownAction: String? = nil,
scrollThreshold: Double = 3.0,
isEnabled: Bool = true,
createdAt: Date = Date()
) {
self.id = id
self.triggerEvent = triggerEvent
self.upAction = upAction
self.downAction = downAction
self.leftAction = leftAction
self.rightAction = rightAction
self.threshold = threshold
self.scrollUpAction = scrollUpAction
self.scrollDownAction = scrollDownAction
self.scrollThreshold = scrollThreshold
self.isEnabled = isEnabled
self.createdAt = createdAt
}

// MARK: - Codable (backward-compatible)

enum CodingKeys: String, CodingKey {
case id, triggerEvent, createdAt
case upAction, downAction, leftAction, rightAction, threshold
case scrollUpAction, scrollDownAction, scrollThreshold
case isEnabled
// Legacy key — present in old data, decoded and discarded
case inputMode
}

func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encode(triggerEvent, forKey: .triggerEvent)
try c.encode(createdAt, forKey: .createdAt)
try c.encodeIfPresent(upAction, forKey: .upAction)
try c.encodeIfPresent(downAction, forKey: .downAction)
try c.encodeIfPresent(leftAction, forKey: .leftAction)
try c.encodeIfPresent(rightAction, forKey: .rightAction)
try c.encode(threshold, forKey: .threshold)
try c.encodeIfPresent(scrollUpAction, forKey: .scrollUpAction)
try c.encodeIfPresent(scrollDownAction, forKey: .scrollDownAction)
try c.encode(scrollThreshold, forKey: .scrollThreshold)
try c.encode(isEnabled, forKey: .isEnabled)
// inputMode intentionally NOT encoded (legacy read-only key)
}

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(UUID.self, forKey: .id)
triggerEvent = try c.decode(RecordedEvent.self, forKey: .triggerEvent)
createdAt = try c.decode(Date.self, forKey: .createdAt)

upAction = try c.decodeIfPresent(String.self, forKey: .upAction)
downAction = try c.decodeIfPresent(String.self, forKey: .downAction)
leftAction = try c.decodeIfPresent(String.self, forKey: .leftAction)
rightAction = try c.decodeIfPresent(String.self, forKey: .rightAction)

// Legacy: threshold was the single threshold field (used for both modes).
// Map it to movement threshold; default 30.0 if absent.
threshold = (try? c.decodeIfPresent(Double.self, forKey: .threshold)) ?? 30.0

scrollUpAction = try c.decodeIfPresent(String.self, forKey: .scrollUpAction)
scrollDownAction = try c.decodeIfPresent(String.self, forKey: .scrollDownAction)
scrollThreshold = (try? c.decodeIfPresent(Double.self, forKey: .scrollThreshold)) ?? 3.0

isEnabled = try c.decode(Bool.self, forKey: .isEnabled)

// inputMode key is silently ignored (no longer used)
}

// MARK: - 方向动作访问 (Movement)

/// 获取指定 Movement 方向的动作名称
func action(for direction: GestureDirection) -> String? {
switch direction {
case .up: return upAction
case .down: return downAction
case .left: return leftAction
case .right: return rightAction
}
}

/// 设置指定 Movement 方向的动作 (返回更新后的副本)
func withAction(_ action: String?, for direction: GestureDirection) -> GestureBinding {
var copy = self
switch direction {
case .up: copy.upAction = action
case .down: copy.downAction = action
case .left: copy.leftAction = action
case .right: copy.rightAction = action
}
return copy
}

// MARK: - 方向动作访问 (Scroll)

/// 获取指定 Scroll 方向的动作名称 (仅 .up / .down 有效)
func scrollAction(for direction: GestureDirection) -> String? {
switch direction {
case .up: return scrollUpAction
case .down: return scrollDownAction
default: return nil
}
}

/// 设置指定 Scroll 方向的动作 (返回更新后的副本)
func withScrollAction(_ action: String?, for direction: GestureDirection) -> GestureBinding {
var copy = self
switch direction {
case .up: copy.scrollUpAction = action
case .down: copy.scrollDownAction = action
default: break
}
return copy
}

// MARK: - 能力查询

var hasAnyMovementAction: Bool {
return upAction != nil || downAction != nil || leftAction != nil || rightAction != nil
}

var hasAnyScrollAction: Bool {
return scrollUpAction != nil || scrollDownAction != nil
}

var hasAnyAction: Bool {
return hasAnyMovementAction || hasAnyScrollAction
}

// MARK: - Equatable

static func == (lhs: GestureBinding, rhs: GestureBinding) -> Bool {
return lhs.id == rhs.id &&
lhs.triggerEvent == rhs.triggerEvent &&
lhs.upAction == rhs.upAction &&
lhs.downAction == rhs.downAction &&
lhs.leftAction == rhs.leftAction &&
lhs.rightAction == rhs.rightAction &&
lhs.threshold == rhs.threshold &&
lhs.scrollUpAction == rhs.scrollUpAction &&
lhs.scrollDownAction == rhs.scrollDownAction &&
lhs.scrollThreshold == rhs.scrollThreshold &&
lhs.isEnabled == rhs.isEnabled &&
lhs.createdAt == rhs.createdAt
}
}
Loading