diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..67c0fede --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# Repository Guidelines(仓库指南) + +## 项目结构与模块划分 + +PlayTools 是供 PlayCover 使用的 Xcode Framework,负责画面控制、按键映射、输入转换及绕过辅助功能。主要源码位于 `PlayTools/`。`PlayTools/Controls/` 负责运行时输入分发与动作执行,`PlayTools/Editor/` 包含游戏内映射编辑界面,`PlayTools/Keymap/` 处理按键名称和布局,`PlayTools/MysticRunes/` 提供绕过与 Swizzle 支持,`PlayTools/Utils/` 存放通用工具。多语言文本位于 `PlayTools/*.lproj/Playtools.strings`,Xcode 项目配置位于 `PlayTools.xcodeproj/`,CocoaPods 元数据位于 `PlayTools.podspec`。 + +配套的 PlayCover 主项目位于 `/Users/wuhu/code/PlayCover`。涉及 PlayTools 集成、依赖更新、完整应用构建或 DMG 打包时,应同时检查该仓库;仅修改 Framework 内部逻辑时,优先在当前 PlayTools 仓库完成和验证。 + +## 构建、测试与开发命令 + +构建 iOS Framework: + +```bash +xcodebuild -project PlayTools.xcodeproj -scheme PlayTools -configuration Debug -destination 'generic/platform=iOS' build +``` + +若本机已安装 SwiftLint,可运行: + +```bash +swiftlint +``` + +测试 PlayCover 集成时,在 `/Users/wuhu/code/PlayCover/Cartfile` 中将依赖指向本仓库、本地分支或标签: + +```text +git "file:///path/to/playtools" "branch-or-tag" +``` + +本地热替换请遵循 README:先构建 iOS 版本,再用 `vtool -set-build-version maccatalyst 11.0 14.0` 修改平台信息,通过 `codesign -fs-` 进行 ad-hoc 签名,最后将 Framework 或二进制复制进 PlayCover。 + +## 编码风格与命名约定 + +Swift 代码应与现有文件保持一致:使用 4 空格缩进、清晰的类型名和简洁的方法名。Objective-C 文件采用常规的 `.h`/`.m` 配对。映射模型应使用能说明用途的名称,例如 `RadialSelectorAction`、`ActionDispatcher` 和 `KeyCodeNames`。SwiftLint 配置位于 `.swiftlint.yml`;项目关闭了 `inclusive_language` 和 `todo` 规则,并允许标识符及类型名包含 `_`。 + +## 测试要求 + +项目目前没有独立的 XCTest Target。修改后至少完成一次干净的 Xcode 构建,并在 PlayCover 中进行针对性手动测试。按键映射改动需要验证导入导出兼容性、编辑器显示、已保存 `.playmap` 的重新加载,以及真实游戏内的运行行为。修改本地化时,确认对应 `Playtools.strings` 已复制到运行中的应用或通过 PlayCover 正确打包。 + +## Commit 与 Pull Request 规范 + +近期提交使用 Conventional Commits 风格的 `feat:`、`fix:`、`merge:` 等前缀。本 Fork 通常使用中英双语摘要,例如 `fix: prioritize radial selector over thumbstick drag / 修复轮盘优先于摇杆拖拽`。每个提交应聚焦单一改动,不要包含生成的构建产物。Pull Request 需说明用户可见的行为变化、手动测试范围和受影响的映射格式;涉及 UI 或编辑器时附上截图或短视频。 + +## Agent 操作注意事项 + +不要恢复或覆盖无关的本地修改。仓库中可能存在用户生成的 Xcode Workspace 文件或本地构建产物;编辑前先检查 `git status`,提交时只包含当前任务所需的文件。 + +任务同时涉及 PlayTools 与 `/Users/wuhu/code/PlayCover` 时,分别检查两个仓库的分支和工作区状态,并分别提交。不要把一个仓库生成的临时依赖、构建产物或签名文件误提交到另一个仓库。 diff --git a/PlayTools.xcodeproj/project.pbxproj b/PlayTools.xcodeproj/project.pbxproj index 555e944e..0c623e2a 100644 --- a/PlayTools.xcodeproj/project.pbxproj +++ b/PlayTools.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 954389CA2B392D7800B063BB /* DraggableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954389C92B392D7800B063BB /* DraggableButton.swift */; }; 954389CC2B39F03D00B063BB /* EditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954389CB2B39F03D00B063BB /* EditorView.swift */; }; 954389CE2B39F26600B063BB /* ElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954389CD2B39F26600B063BB /* ElementView.swift */; }; + 954389D42B3A6CD000B063BB /* KeymapHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954389D52B3A6CD000B063BB /* KeymapHUDView.swift */; }; 954389D22B3A5AE100B063BB /* ElementController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954389D12B3A5AE100B063BB /* ElementController.swift */; }; 9555ED2F2C058E08006E469C /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9555ED2E2C058E08006E469C /* DebugView.swift */; }; 9555ED312C058E28006E469C /* DebugModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9555ED302C058E28006E469C /* DebugModel.swift */; }; @@ -32,6 +33,7 @@ 9562D15A2AB4FBBB002C329D /* TransparentKeyboardEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D1592AB4FBBB002C329D /* TransparentKeyboardEventAdapter.swift */; }; 9562D15C2AB4FC79002C329D /* TransparentMouseEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D15B2AB4FC79002C329D /* TransparentMouseEventAdapter.swift */; }; 9562D15E2AB4FCB8002C329D /* TouchscreenControllerEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D15D2AB4FCB8002C329D /* TouchscreenControllerEventAdapter.swift */; }; + A10000012C00000100000001 /* VirtualCursorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000002C00000100000001 /* VirtualCursorController.swift */; }; 9562D1602AB4FD46002C329D /* TransparentControllerEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D15F2AB4FD46002C329D /* TransparentControllerEventAdapter.swift */; }; 9562D1622AB4FE73002C329D /* CameraControlMouseEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D1612AB4FE73002C329D /* CameraControlMouseEventAdapter.swift */; }; 9562D1642AB4FEB4002C329D /* TouchscreenMouseEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D1632AB4FEB4002C329D /* TouchscreenMouseEventAdapter.swift */; }; @@ -118,6 +120,7 @@ 954389C72B392D5300B063BB /* Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Button.swift; sourceTree = ""; }; 954389C92B392D7800B063BB /* DraggableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableButton.swift; sourceTree = ""; }; 954389CB2B39F03D00B063BB /* EditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorView.swift; sourceTree = ""; }; + 954389D52B3A6CD000B063BB /* KeymapHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeymapHUDView.swift; sourceTree = ""; }; 954389CD2B39F26600B063BB /* ElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementView.swift; sourceTree = ""; }; 954389D12B3A5AE100B063BB /* ElementController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementController.swift; sourceTree = ""; }; 9555ED2E2C058E08006E469C /* DebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugView.swift; sourceTree = ""; }; @@ -129,6 +132,7 @@ 9562D1592AB4FBBB002C329D /* TransparentKeyboardEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentKeyboardEventAdapter.swift; sourceTree = ""; }; 9562D15B2AB4FC79002C329D /* TransparentMouseEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentMouseEventAdapter.swift; sourceTree = ""; }; 9562D15D2AB4FCB8002C329D /* TouchscreenControllerEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchscreenControllerEventAdapter.swift; sourceTree = ""; }; + A10000002C00000100000001 /* VirtualCursorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualCursorController.swift; sourceTree = ""; }; 9562D15F2AB4FD46002C329D /* TransparentControllerEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentControllerEventAdapter.swift; sourceTree = ""; }; 9562D1612AB4FE73002C329D /* CameraControlMouseEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraControlMouseEventAdapter.swift; sourceTree = ""; }; 9562D1632AB4FEB4002C329D /* TouchscreenMouseEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchscreenMouseEventAdapter.swift; sourceTree = ""; }; @@ -237,6 +241,7 @@ AA71978C287A481500623C15 /* CircleMenu */, AA71979D287A481500623C15 /* EditorCircleMenu.swift */, 954389CB2B39F03D00B063BB /* EditorView.swift */, + 954389D52B3A6CD000B063BB /* KeymapHUDView.swift */, ); path = Views; sourceTree = ""; @@ -381,6 +386,7 @@ isa = PBXGroup; children = ( 9562D14F2AB4849C002C329D /* EventAdapter */, + A10000002C00000100000001 /* VirtualCursorController.swift */, 9562D1782AB64458002C329D /* ModeAutomaton.swift */, AA719756287A480C00623C15 /* ControlMode.swift */, ); @@ -727,6 +733,7 @@ 954389C22B38922400B063BB /* MouseArea.swift in Sources */, AA7197AB287A481500623C15 /* Element.swift in Sources */, AA7197AE287A481500623C15 /* EditorCircleMenu.swift in Sources */, + 954389D42B3A6CD000B063BB /* KeymapHUDView.swift in Sources */, 954389D22B3A5AE100B063BB /* ElementController.swift in Sources */, 95D474FF2C0BDAB20072797F /* DraggableButtonElement.swift in Sources */, 9562D1622AB4FE73002C329D /* CameraControlMouseEventAdapter.swift in Sources */, @@ -769,6 +776,7 @@ AA7197A9287A481500623C15 /* PlayInfo.swift in Sources */, AA71986A287A81A000623C15 /* PTFakeMetaTouch.m in Sources */, 9562D15E2AB4FCB8002C329D /* TouchscreenControllerEventAdapter.swift in Sources */, + A10000012C00000100000001 /* VirtualCursorController.swift in Sources */, 954389C62B3896E600B063BB /* ChildButton.swift in Sources */, ABCECEE629750BA600746595 /* PlayedApple.swift in Sources */, AA71986C287A81A000623C15 /* NSObject+Swizzle.m in Sources */, diff --git a/PlayTools/Controls/ActionDispatcher.swift b/PlayTools/Controls/ActionDispatcher.swift index 000cf1a1..5c1d8540 100644 --- a/PlayTools/Controls/ActionDispatcher.swift +++ b/PlayTools/Controls/ActionDispatcher.swift @@ -20,11 +20,13 @@ public enum ActionDispatchPriority: Int { public class ActionDispatcher { static private let keymapVersion = "2.0." static private var actions = [Action]() - static private var buttonHandlers: [String: [(Bool) -> Void]] = [:] + static private var buttonHandlers: [String: [ButtonPressHandler]] = [:] + static private var pressedKeys = Set() + static private let pressedKeysLock = NSLock() static private let priorityCount = 3 // You can't put more than 8 cameras or 8 joysticks in a keymap right? - static private let mappingCountPerPriority = 8 + static private let mappingCountPerPriority = 16 static private let directionPadHandlers: [[ManagedAtomic]] = Array( (0..(.EMPTY)}) @@ -35,6 +37,9 @@ public class ActionDispatcher { invalidateActions() actions = [] buttonHandlers.removeAll(keepingCapacity: true) + pressedKeysLock.lock() + pressedKeys.removeAll(keepingCapacity: true) + pressedKeysLock.unlock() directionPadHandlers.forEach({ handlers in handlers.forEach({ handler in handler.store(.EMPTY, ordering: .relaxed) @@ -49,6 +54,9 @@ public class ActionDispatcher { clear() actions.append(FakeMouseAction()) + if ShoulderKeymapSwitchAction.isEnabled { + actions.append(ShoulderKeymapSwitchAction()) + } // current keymap version is 2.0.x. // in future, keymap format will be upgraded. @@ -79,6 +87,14 @@ public class ActionDispatcher { actions.append(CameraAction(data: mouse)) } + for swipe in keymap.currentKeymap.swipeModels { + actions.append(TriggeredSwipeAction(data: swipe)) + } + + for radialSelector in keymap.currentKeymap.radialSelectorModels { + actions.append(RadialSelectorAction(data: radialSelector)) + } + for joystick in keymap.currentKeymap.joystickModel { // Left Thumbstick, Right Thumbstick, Mouse if JoystickModel.isAnalog(joystick) { @@ -96,18 +112,32 @@ public class ActionDispatcher { } static public func register(key: String, handler: @escaping (Bool) -> Void) { + register(key: key, modifierKeys: [], handler: handler) + } + + static public func register(key: String, + modifierKeys: [String], + handler: @escaping (Bool) -> Void) { // this function is called when setting up `button` type of mapping if buttonHandlers[key] == nil { buttonHandlers[key] = [] } - buttonHandlers[key]!.append(handler) + buttonHandlers[key]!.append(ButtonPressHandler(modifierKeys: modifierKeys, handle: handler)) } static public func register(key: String, handler: @escaping (CGFloat, CGFloat) -> Void, priority: ActionDispatchPriority = .DEFAULT) { - let atomicHandler = directionPadHandlers[priority.rawValue].first(where: { handler in - handler.load(ordering: .relaxed).key == key + register(key: key, modifierKeys: [], handler: handler, priority: priority) + } + + static public func register(key: String, + modifierKeys: [String], + handler: @escaping (CGFloat, CGFloat) -> Void, + priority: ActionDispatchPriority = .DEFAULT) { + let atomicHandler = directionPadHandlers[priority.rawValue].first(where: { storedHandler in + let handlerValue = storedHandler.load(ordering: .relaxed) + return handlerValue.key == key && handlerValue.modifierKeys == modifierKeys }) ?? directionPadHandlers[priority.rawValue].first(where: { handler in handler.load(ordering: .relaxed).key.isEmpty @@ -119,13 +149,16 @@ public class ActionDispatcher { // Toast.showHint(title: "register", // text: ["key: \(key), atomicHandler: \(String(describing: atomicHandler))"]) // } - atomicHandler?.store(AtomicHandler(key, handler), ordering: .releasing) + atomicHandler?.store(AtomicHandler(key, modifierKeys, handler), ordering: .releasing) } - static public func unregister(key: String) { + static public func unregister(key: String, + modifierKeys: [String] = [], + priority: ActionDispatchPriority = .DRAGGABLE) { // Only draggable can be unregistered - let atomicHandler = directionPadHandlers[ActionDispatchPriority.DRAGGABLE.rawValue].first(where: { handler in - handler.load(ordering: .relaxed).key == key + let atomicHandler = directionPadHandlers[priority.rawValue].first(where: { handler in + let handlerValue = handler.load(ordering: .relaxed) + return handlerValue.key == key && handlerValue.modifierKeys == modifierKeys }) // DispatchQueue.main.async { // if screen.keyWindow == nil { @@ -172,57 +205,105 @@ public class ActionDispatcher { } static public func getDispatchPriority(key: String) -> ActionDispatchPriority? { - if let priority = directionPadHandlers.firstIndex(where: { handlers in - handlers.contains(where: { handler in - handler.load(ordering: .acquiring).key == key - }) - }) { -// Toast.showHint(title: "\(key) priority", text: ["\(priority)"]) - return ActionDispatchPriority(rawValue: priority) + for priority in 0.. Bool { + pressedKeysLock.lock() + if pressed { + pressedKeys.insert(key) + } else { + pressedKeys.remove(key) + } + pressedKeysLock.unlock() guard let handlers = buttonHandlers[key] else { return false } - var mapped = false - for handler in handlers { + let modifiedHandlers = handlers.filter { handler in + !handler.modifierKeys.isEmpty && isPressed(anyOf: handler.modifierKeys) + } + let selectedHandlers = modifiedHandlers.isEmpty ? + handlers.filter { $0.modifierKeys.isEmpty } : + modifiedHandlers + for handler in selectedHandlers { PlayInput.touchQueue.async(qos: .userInteractive, execute: { - handler(pressed) + handler.handle(pressed) }) - mapped = true } // return value matters. A false value makes a beep sound - return mapped + return !selectedHandlers.isEmpty + } + + static public func isPressed(anyOf keys: [String]) -> Bool { + pressedKeysLock.lock() + let result = keys.contains { pressedKeys.contains($0) } + pressedKeysLock.unlock() + return result } static public func dispatch(key: String, valueX: CGFloat, valueY: CGFloat) -> Bool { for priority in 0.. Void +} + private final class AtomicHandler: AtomicReference { - static fileprivate let EMPTY = AtomicHandler("", {_, _ in }) + static fileprivate let EMPTY = AtomicHandler("", [], {_, _ in }) let key: String + let modifierKeys: [String] let handle: (CGFloat, CGFloat) -> Void - init(_ key: String, _ handle: @escaping (CGFloat, CGFloat) -> Void) { + init(_ key: String, _ modifierKeys: [String], _ handle: @escaping (CGFloat, CGFloat) -> Void) { self.key = key + self.modifierKeys = modifierKeys self.handle = handle } } diff --git a/PlayTools/Controls/Backend/Action/PlayAction.swift b/PlayTools/Controls/Backend/Action/PlayAction.swift index dcf09297..53af9d76 100644 --- a/PlayTools/Controls/Backend/Action/PlayAction.swift +++ b/PlayTools/Controls/Backend/Action/PlayAction.swift @@ -5,6 +5,7 @@ // swiftlint:disable file_length import Foundation +import UIKit protocol Action { func invalidate() @@ -13,23 +14,46 @@ protocol Action { class ButtonAction: Action { func invalidate() { - Toucher.touchcam(point: point, phase: UITouch.Phase.ended, tid: &id, - actionName: "Button", keyName: keyName) + keyIsPressed = false + cancelPendingHold() + if touchIsActive { + touchIsActive = false + releaseTouch() + } } let keyCode: Int let keyName: String + let modifierKeyCode: Int? + let modifierKeyName: String? + private let modifierKeys: [String] + private let holdDuration: CGFloat? let point: CGPoint var id: Int? + private var keyIsPressed = false + private var touchIsActive = false + private var pendingHold: DispatchWorkItem? - init(keyCode: Int, keyName: String, point: CGPoint) { + init(keyCode: Int, + keyName: String, + modifierKeyCode: Int? = nil, + modifierKeyName: String? = nil, + holdDuration: CGFloat? = nil, + point: CGPoint) { self.keyCode = keyCode self.keyName = keyName + self.modifierKeyCode = modifierKeyCode + self.modifierKeyName = modifierKeyName + self.modifierKeys = Self.dispatchNames(code: modifierKeyCode, name: modifierKeyName) + self.holdDuration = holdDuration self.point = point - let code = keyCode - let codeName = KeyCodeNames.keyCodes[code] ?? "Btn" // TODO: set both key names in draggable button, so as to depracate key code - ActionDispatcher.register(key: code == KeyCodeNames.defaultCode ? keyName: codeName, handler: self.update) + for key in Self.dispatchNames(code: keyCode, name: keyName) { + ActionDispatcher.register(key: key, modifierKeys: modifierKeys, handler: self.update) + } + for key in modifierKeys { + ActionDispatcher.register(key: key, handler: self.updateModifier) + } } convenience init(data: Button) { @@ -37,6 +61,9 @@ class ButtonAction: Action { self.init( keyCode: keyCode, keyName: data.keyName, + modifierKeyCode: data.modifierKeyCode, + modifierKeyName: data.modifierKeyName, + holdDuration: data.holdDuration, point: CGPoint( x: data.transform.xCoord.absoluteX, y: data.transform.yCoord.absoluteY)) @@ -44,48 +71,660 @@ class ButtonAction: Action { func update(pressed: Bool) { if pressed { - Toucher.touchcam(point: point, phase: UITouch.Phase.began, tid: &id, - actionName: "Button", keyName: keyName) + guard !keyIsPressed else { + return + } + keyIsPressed = true + beginAfterHoldIfNeeded() } else { - Toucher.touchcam(point: point, phase: UITouch.Phase.ended, tid: &id, - actionName: "Button", keyName: keyName) + guard keyIsPressed else { + return + } + keyIsPressed = false + cancelPendingHold() + if touchIsActive { + touchIsActive = false + releaseTouch() + } + } + } + + private func updateModifier(pressed: Bool) { + if !pressed { + keyIsPressed = false + cancelPendingHold() + if touchIsActive { + touchIsActive = false + releaseTouch() + } + } + } + + private func beginAfterHoldIfNeeded() { + guard let holdDuration = holdDuration else { + touchIsActive = true + beginTouch() + return + } + let workItem = DispatchWorkItem { [weak self] in + guard let self = self, self.keyIsPressed else { + return + } + self.pendingHold = nil + self.touchIsActive = true + self.beginTouch() } + pendingHold = workItem + PlayInput.touchQueue.asyncAfter(deadline: .now() + Double(holdDuration), + execute: workItem) + } + + private func cancelPendingHold() { + pendingHold?.cancel() + pendingHold = nil + } + + func beginTouch() { + Toucher.touchcam(point: point, phase: UITouch.Phase.began, tid: &id, + actionName: "Button", keyName: keyName) + } + + func releaseTouch() { + Toucher.touchcam(point: point, phase: UITouch.Phase.ended, tid: &id, + actionName: "Button", keyName: keyName) + } + + fileprivate static func dispatchNames(code: Int?, name: String?) -> [String] { + guard let name = name, !name.isEmpty else { + return [] + } + + let resolvedCode = KeyCodeNames.keyCodeByName[name] ?? code + if let resolvedCode = resolvedCode, resolvedCode != KeyCodeNames.defaultCode { + return KeyCodeNames.dispatchNames(for: resolvedCode, fallback: name) + } + return [name] } } -class DraggableButtonAction: ButtonAction { - var releasePoint: CGPoint +class TriggeredSwipeAction: Action { + private let keyName: String + private let modifierKeys: [String] + private let holdDuration: CGFloat? + private let startPoint: CGPoint + private let endPoint: CGPoint + private var keyIsPressed = false + private var pendingHold: DispatchWorkItem? + private var id: Int? - override init(keyCode: Int, keyName: String, point: CGPoint) { - self.releasePoint = point - super.init(keyCode: keyCode, keyName: keyName, point: point) + init(data: Swipe) { + self.keyName = data.keyName + self.modifierKeys = ButtonAction.dispatchNames(code: data.modifierKeyCode, + name: data.modifierKeyName) + self.holdDuration = data.holdDuration + self.startPoint = CGPoint( + x: data.transform.xCoord.absoluteX, + y: data.transform.yCoord.absoluteY) + let length = data.transform.size.absoluteSize + self.endPoint = CGPoint( + x: startPoint.x + cos(data.angle) * length, + y: startPoint.y + sin(data.angle) * length) + + for key in ButtonAction.dispatchNames(code: data.keyCode, name: data.keyName) { + ActionDispatcher.register(key: key, modifierKeys: modifierKeys, handler: self.update) + } + for key in modifierKeys { + ActionDispatcher.register(key: key, handler: self.updateModifier) + } } - override func update(pressed: Bool) { + func update(pressed: Bool) { if pressed { - Toucher.touchcam(point: point, phase: UITouch.Phase.began, tid: &id, - actionName: "DraggableButton", keyName: keyName) - self.releasePoint = point - ActionDispatcher.register(key: KeyCodeNames.mouseMove, - handler: self.onMouseMoved, - priority: .DRAGGABLE) - if !mode.cursorHidden() { - AKInterface.shared!.hideCursor() + guard !keyIsPressed else { + return } + keyIsPressed = true + performAfterHoldIfNeeded() } else { - Toucher.touchcam(point: releasePoint, phase: UITouch.Phase.ended, tid: &id, - actionName: "DraggableButton", keyName: keyName) - if id == nil { - ActionDispatcher.unregister(key: KeyCodeNames.mouseMove) - if !mode.cursorHidden() { - AKInterface.shared!.unhideCursor() - } + keyIsPressed = false + cancelPendingHold() + } + } + + private func updateModifier(pressed: Bool) { + if !pressed { + keyIsPressed = false + cancelPendingHold() + } + } + + private func performAfterHoldIfNeeded() { + guard let holdDuration = holdDuration else { + performSwipe() + return + } + let workItem = DispatchWorkItem { [weak self] in + guard let self = self, self.keyIsPressed else { + return + } + self.pendingHold = nil + self.performSwipe() + } + pendingHold = workItem + PlayInput.touchQueue.asyncAfter(deadline: .now() + Double(holdDuration), + execute: workItem) + } + + private func cancelPendingHold() { + pendingHold?.cancel() + pendingHold = nil + } + + private func performSwipe() { + guard id == nil else { + return + } + let length = hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y) + let stepCount = 12 + let totalDuration = Self.swipeDuration(for: length) + Toucher.touchcam(point: startPoint, phase: UITouch.Phase.began, tid: &id, + actionName: "Swipe", keyName: keyName) + for step in 1...stepCount { + let progress = CGFloat(step) / CGFloat(stepCount) + let easedProgress = Self.easeInOut(progress) + let point = CGPoint( + x: startPoint.x + (endPoint.x - startPoint.x) * easedProgress, + y: startPoint.y + (endPoint.y - startPoint.y) * easedProgress) + PlayInput.touchQueue.asyncAfter(deadline: .now() + totalDuration * Double(progress), + qos: .userInteractive) { + Toucher.touchcam(point: point, phase: UITouch.Phase.moved, tid: &self.id, + actionName: "Swipe", keyName: self.keyName) + } + } + PlayInput.touchQueue.asyncAfter(deadline: .now() + totalDuration, + qos: .userInteractive) { + Toucher.touchcam(point: self.endPoint, phase: UITouch.Phase.ended, tid: &self.id, + actionName: "Swipe", keyName: self.keyName) + } + } + + private static func swipeDuration(for length: CGFloat) -> Double { + let normalized = min(max(Double(length) / 420.0, 0.0), 1.0) + return 0.18 + normalized * 0.16 + } + + private static func easeInOut(_ progress: CGFloat) -> CGFloat { + progress * progress * (3 - 2 * progress) + } + + func invalidate() { + keyIsPressed = false + cancelPendingHold() + Toucher.touchcam(point: endPoint, phase: UITouch.Phase.ended, tid: &id, + actionName: "Swipe", keyName: keyName) + } +} + +enum KeymapSwitchDirection { + case next + case previous +} + +enum KeymapSwitcher { + @discardableResult + static func switchKeymap(_ direction: KeymapSwitchDirection) -> Bool { + guard Thread.isMainThread else { + DispatchQueue.main.async { + switchKeymap(direction) + } + return true + } + + guard keymap.keymapCount > 1 else { + Toast.showHint(title: NSLocalizedString("hint.keymapping.onlyOneKeymap", + tableName: "Playtools", + value: "Only one keymap", + comment: "")) + return false + } + + switch direction { + case .next: + keymap.nextKeymap() + case .previous: + keymap.previousKeymap() + } + + let format = NSLocalizedString("hint.keymapping.switchedKeymap", + tableName: "Playtools", + value: "Switched keymap: %@", + comment: "") + Toast.showHint(title: String(format: format, keymap.currentKeymapName)) + ActionDispatcher.build() + EditorController.shared.refreshHUD() + return true + } +} + +class ShoulderKeymapSwitchAction: Action { + static private let userDefaultsKey = "playtools.shoulderKeymapSwitchEnabled" + static private let defaultEnabled = true + + static var isEnabled: Bool { + get { + guard UserDefaults.standard.object(forKey: userDefaultsKey) != nil else { + return defaultEnabled + } + return UserDefaults.standard.bool(forKey: userDefaultsKey) + } + set { + UserDefaults.standard.set(newValue, forKey: userDefaultsKey) + } + } + + private let cooldown: TimeInterval = 0.3 + private var lastSwitchDate = Date.distantPast + + init() { + let leftShoulder = "Left Shoulder" + let rightShoulder = "Right Shoulder" + ActionDispatcher.register(key: rightShoulder, + modifierKeys: [leftShoulder], + handler: { [weak self] pressed in + self?.update(pressed: pressed, direction: .next) + }) + ActionDispatcher.register(key: leftShoulder, + modifierKeys: [rightShoulder], + handler: { [weak self] pressed in + self?.update(pressed: pressed, direction: .previous) + }) + } + + func invalidate() {} + + private func update(pressed: Bool, direction: KeymapSwitchDirection) { + guard pressed else { + return + } + guard Date().timeIntervalSince(lastSwitchDate) >= cooldown else { + return + } + lastSwitchDate = Date() + + KeymapSwitcher.switchKeymap(direction) + } +} + +private class RadialSelectorOverlay: UIView { + private let wheelSize: CGFloat = 192 + private let labelSize: CGFloat = 44 + private let selectorCenter: CGPoint + private let targetPoints: [CGPoint] + private let wheelContainer = UIView() + private let selectionLabel = UILabel() + private let labels = RadialSelectorModel.defaultSlots.map { slot -> UILabel in + let label = UILabel() + label.text = slot.title + label.textAlignment = .center + label.font = UIFont.systemFont(ofSize: 20, weight: .bold) + label.textColor = .white + label.backgroundColor = UIColor.black.withAlphaComponent(0.7) + label.layer.cornerRadius = 16 + label.clipsToBounds = true + return label + } + private let targetMarkers: [UILabel] + + init(selectorCenter: CGPoint, targetPoints: [CGPoint]) { + self.selectorCenter = selectorCenter + self.targetPoints = targetPoints + self.targetMarkers = RadialSelectorModel.defaultSlots.enumerated().map { index, slot in + let label = UILabel() + label.text = slot.title + label.textAlignment = .center + label.font = UIFont.systemFont(ofSize: 30, weight: .bold) + label.textColor = .white + label.backgroundColor = UIColor.systemTeal.withAlphaComponent(0.8) + label.layer.cornerRadius = 28 + label.clipsToBounds = true + label.layer.borderColor = UIColor.white.withAlphaComponent(0.5).cgColor + label.layer.borderWidth = 3 + if !targetPoints.indices.contains(index) { + label.isHidden = true + } + return label + } + super.init(frame: screen.screenRect) + backgroundColor = UIColor.clear + isUserInteractionEnabled = false + wheelContainer.backgroundColor = UIColor.black.withAlphaComponent(0.5) + wheelContainer.layer.cornerRadius = wheelSize / 2 + wheelContainer.layer.borderColor = UIColor.systemTeal.withAlphaComponent(0.9).cgColor + wheelContainer.layer.borderWidth = 3 + wheelContainer.frame = CGRect( + x: selectorCenter.x - wheelSize / 2, + y: selectorCenter.y - wheelSize / 2, + width: wheelSize, + height: wheelSize + ) + addSubview(wheelContainer) + + selectionLabel.textAlignment = .center + selectionLabel.font = UIFont.systemFont(ofSize: 44, weight: .heavy) + selectionLabel.textColor = .white + selectionLabel.backgroundColor = UIColor.black.withAlphaComponent(0.55) + selectionLabel.layer.cornerRadius = 30 + selectionLabel.clipsToBounds = true + wheelContainer.addSubview(selectionLabel) + + labels.forEach(wheelContainer.addSubview) + targetMarkers.forEach(addSubview) + update(selectedIndex: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + wheelContainer.frame = CGRect( + x: selectorCenter.x - wheelSize / 2, + y: selectorCenter.y - wheelSize / 2, + width: wheelSize, + height: wheelSize + ) + selectionLabel.frame = CGRect(x: 48, y: 66, width: 96, height: 60) + + let center = CGPoint(x: wheelContainer.bounds.midX, y: wheelContainer.bounds.midY) + let radius: CGFloat = 66 + for (index, label) in labels.enumerated() { + let angle = RadialSelectorModel.defaultSlots[index].angle + label.frame = CGRect( + x: center.x + cos(angle) * radius - labelSize / 2, + y: center.y + sin(angle) * radius - labelSize / 2, + width: labelSize, + height: labelSize + ) + label.layer.cornerRadius = labelSize / 2 + } + + for (index, marker) in targetMarkers.enumerated() where targetPoints.indices.contains(index) { + let point = targetPoints[index] + marker.frame = CGRect(x: point.x - 28, y: point.y - 28, width: 56, height: 56) + } + } + + func update(selectedIndex: Int?) { + for (index, label) in labels.enumerated() { + let selected = selectedIndex == index + label.backgroundColor = selected + ? UIColor.systemGreen.withAlphaComponent(0.92) + : UIColor.black.withAlphaComponent(0.7) + label.alpha = selected ? 1 : 0.28 + label.transform = selected ? CGAffineTransform(scaleX: 1.35, y: 1.35) : .identity + } + for (index, marker) in targetMarkers.enumerated() { + let selected = selectedIndex == index + marker.backgroundColor = selected + ? UIColor.systemGreen.withAlphaComponent(0.92) + : UIColor.systemTeal.withAlphaComponent(0.8) + marker.alpha = selected ? 1 : 0.45 + marker.transform = selected ? CGAffineTransform(scaleX: 1.35, y: 1.35) : .identity + } + selectionLabel.text = selectedIndex.flatMap { labels.indices.contains($0) ? labels[$0].text : nil } ?? "RS" + selectionLabel.backgroundColor = selectedIndex == nil + ? UIColor.black.withAlphaComponent(0.55) + : UIColor.systemGreen.withAlphaComponent(0.9) + wheelContainer.layer.borderColor = (selectedIndex == nil + ? UIColor.systemTeal.withAlphaComponent(0.9) + : UIColor.systemGreen.withAlphaComponent(0.95)).cgColor + } +} + +class RadialSelectorAction: Action { + private let edgeTriggerThreshold: CGFloat = 0.92 + private let keyName: String + private let modifierKeys: [String] + private let holdDuration: CGFloat? + private let threshold: CGFloat + private let points: [CGPoint] + private let selectorCenter: CGPoint + private var modifierKeyDown = false + private var modifierPressed = false + private var pendingHold: DispatchWorkItem? + private var lastEdgeTriggeredIndex: Int? + private var selectedIndex: Int? + private weak var overlay: RadialSelectorOverlay? + + init(data: RadialSelector) { + self.keyName = data.keyName + self.modifierKeys = ButtonAction.dispatchNames(code: data.modifierKeyCode, + name: data.modifierKeyName) + self.holdDuration = data.holdDuration + self.threshold = min(max(data.activationThreshold ?? 0.55, 0.2), 0.35) + self.selectorCenter = CGPoint( + x: data.transform.xCoord.absoluteX, + y: data.transform.yCoord.absoluteY + ) + self.points = data.slots.map { + CGPoint(x: $0.target.xCoord.absoluteX, y: $0.target.yCoord.absoluteY) + } + + ActionDispatcher.register(key: keyName, + modifierKeys: modifierKeys, + handler: self.thumbstickUpdated, + priority: .DRAGGABLE) + for modifierKey in modifierKeys { + ActionDispatcher.register(key: modifierKey, handler: self.modifierUpdated) + } + } + + func invalidate() { + modifierKeyDown = false + modifierPressed = false + cancelPendingHold() + lastEdgeTriggeredIndex = nil + selectedIndex = nil + hideOverlay() + } + + private func modifierUpdated(pressed: Bool) { + modifierKeyDown = pressed + if pressed { + startAfterHoldIfNeeded() + } else { + cancelPendingHold() + guard modifierPressed else { + return + } + modifierPressed = false + let alreadyTriggered = lastEdgeTriggeredIndex != nil + let selectedPoint = selectedIndex.flatMap { points.indices.contains($0) ? points[$0] : nil } + lastEdgeTriggeredIndex = nil + selectedIndex = nil + hideOverlay() + guard !alreadyTriggered else { + return + } + guard let selectedPoint = selectedPoint else { + return + } + triggerTap(at: selectedPoint) + } + } + + private func startAfterHoldIfNeeded() { + guard let holdDuration = holdDuration else { + activate() + return + } + let workItem = DispatchWorkItem { [weak self] in + self?.pendingHold = nil + self?.activate() + } + pendingHold = workItem + PlayInput.touchQueue.asyncAfter(deadline: .now() + Double(holdDuration), + execute: workItem) + } + + private func activate() { + guard modifierKeyDown else { + return + } + guard !modifierPressed else { + return + } + modifierPressed = true + lastEdgeTriggeredIndex = nil + showOverlay() + } + + private func cancelPendingHold() { + pendingHold?.cancel() + pendingHold = nil + } + + private func thumbstickUpdated(_ valueX: CGFloat, _ valueY: CGFloat) { + guard modifierPressed else { + return + } + let magnitude = hypot(valueX, valueY) + guard magnitude >= threshold else { + selectedIndex = nil + lastEdgeTriggeredIndex = nil + DispatchQueue.main.async { + self.overlay?.update(selectedIndex: nil) + } + return + } + let angle = normalizedAngle(atan2(-valueY, valueX)) + let newIndex = nearestSlotIndex(for: angle) + if newIndex != selectedIndex { + selectedIndex = newIndex + DispatchQueue.main.async { + self.overlay?.update(selectedIndex: newIndex) + } + } + + if magnitude >= edgeTriggerThreshold, + lastEdgeTriggeredIndex != newIndex, + points.indices.contains(newIndex) { + lastEdgeTriggeredIndex = newIndex + triggerTap(at: points[newIndex]) + } else if magnitude < edgeTriggerThreshold { + lastEdgeTriggeredIndex = nil + } + } + + private func normalizedAngle(_ angle: CGFloat) -> CGFloat { + let twoPi = CGFloat.pi * 2 + let remainder = angle.truncatingRemainder(dividingBy: twoPi) + return remainder >= 0 ? remainder : remainder + twoPi + } + + private func nearestSlotIndex(for angle: CGFloat) -> Int { + RadialSelectorModel.defaultSlots.enumerated().min { lhs, rhs in + angularDistance(lhs.element.angle, angle) < angularDistance(rhs.element.angle, angle) + }?.offset ?? 0 + } + + private func angularDistance(_ lhs: CGFloat, _ rhs: CGFloat) -> CGFloat { + let twoPi = CGFloat.pi * 2 + let distance = abs(lhs - rhs).truncatingRemainder(dividingBy: twoPi) + return min(distance, twoPi - distance) + } + + private func triggerTap(at point: CGPoint) { + var touchId: Int? + Toucher.touchcam(point: point, phase: .began, tid: &touchId, + actionName: "RadialSelector", keyName: keyName) + PlayInput.touchQueue.asyncAfter(deadline: .now() + 0.05, qos: .userInteractive) { + Toucher.touchcam(point: point, phase: .ended, tid: &touchId, + actionName: "RadialSelector", keyName: self.keyName) + } + } + + private func showOverlay() { + DispatchQueue.main.async { + if let overlay = self.overlay { + overlay.isHidden = false + overlay.center = self.selectorCenter + overlay.update(selectedIndex: self.selectedIndex) + return + } + let overlay = RadialSelectorOverlay(selectorCenter: self.selectorCenter, + targetPoints: self.points) + overlay.update(selectedIndex: self.selectedIndex) + screen.keyWindow?.addSubview(overlay) + self.overlay = overlay + } + } + + private func hideOverlay() { + DispatchQueue.main.async { + self.overlay?.removeFromSuperview() + self.overlay = nil + } + } +} + +class DraggableButtonAction: ButtonAction { + var releasePoint: CGPoint + + override init(keyCode: Int, + keyName: String, + modifierKeyCode: Int? = nil, + modifierKeyName: String? = nil, + holdDuration: CGFloat? = nil, + point: CGPoint) { + self.releasePoint = point + super.init( + keyCode: keyCode, + keyName: keyName, + modifierKeyCode: modifierKeyCode, + modifierKeyName: modifierKeyName, + holdDuration: holdDuration, + point: point) + } + + convenience init(data: Button) { + self.init( + keyCode: data.keyCode, + keyName: data.keyName, + modifierKeyCode: data.modifierKeyCode, + modifierKeyName: data.modifierKeyName, + holdDuration: data.holdDuration, + point: CGPoint( + x: data.transform.xCoord.absoluteX, + y: data.transform.yCoord.absoluteY)) + } + + override func beginTouch() { + Toucher.touchcam(point: point, phase: UITouch.Phase.began, tid: &id, + actionName: "DraggableButton", keyName: keyName) + self.releasePoint = point + ActionDispatcher.register(key: keyName, + handler: self.onMouseMoved, + priority: .DRAGGABLE) + if keyName == KeyCodeNames.mouseMove && !mode.cursorHidden() { + AKInterface.shared!.hideCursor() + } + } + + override func releaseTouch() { + Toucher.touchcam(point: releasePoint, phase: UITouch.Phase.ended, tid: &id, + actionName: "DraggableButton", keyName: keyName) + if id == nil { + ActionDispatcher.unregister(key: keyName) + if keyName == KeyCodeNames.mouseMove && !mode.cursorHidden() { + AKInterface.shared!.unhideCursor() } } } override func invalidate() { - ActionDispatcher.unregister(key: KeyCodeNames.mouseMove) + ActionDispatcher.unregister(key: keyName) super.invalidate() } @@ -189,8 +828,10 @@ class JoystickAction: Action { self.mode = mode for index in 0.. Void] = [:] + static private var thumbstickCursorControl: [String: ThumbstickCursorControl] = [:] public func handleValueChanged(_ profile: GCExtendedGamepad, _ element: GCControllerElement) { let name: String = element.aliases.first! if let buttonElement = element as? GCControllerButtonInput { + if element === profile.rightThumbstickButton { + if buttonElement.isPressed { + if !VirtualCursorController.shared.isActive { + TouchscreenControllerEventAdapter.stopThumbstickMotion(named: "Right Thumbstick") + } + VirtualCursorController.shared.toggle() + } + return + } + if VirtualCursorController.shared.isActive { + if element === profile.leftTrigger { + VirtualCursorController.shared.handleTrigger(pressed: buttonElement.isPressed) + return + } + if name == "Direction Pad Up" || name == "Direction Pad Down" || + name == "Direction Pad Left" || name == "Direction Pad Right" { + VirtualCursorController.shared.handleDirectionalDrag(key: name, + pressed: buttonElement.isPressed) + return + } + } _ = ActionDispatcher.dispatch(key: name, pressed: buttonElement.isPressed) } else if let dpadElement = element as? GCControllerDirectionPad { handleDirectionPad(profile, dpadElement) @@ -50,16 +71,39 @@ public class TouchscreenControllerEventAdapter: ControllerEventAdapter { let deltaX = xAxis.value, deltaY = yAxis.value let cgDx = CGFloat(deltaX) let cgDy = CGFloat(deltaY) + if name == "Right Thumbstick", VirtualCursorController.shared.isActive { + VirtualCursorController.shared.update(velocityX: cgDx, velocityY: cgDy) + return + } let dispatchType = ActionDispatcher.getDispatchPriority(key: name) if dispatchType == nil { return } else if dispatchType == .DEFAULT { - _ = ActionDispatcher.dispatch(key: name, valueX: cgDx, valueY: cgDy) + if name == "Right Thumbstick" { + _ = ActionDispatcher.dispatch(key: name, + valueX: acceleratedViewAxis(cgDx), + valueY: acceleratedViewAxis(cgDy)) + } else { + _ = ActionDispatcher.dispatch(key: name, valueX: cgDx, valueY: cgDy) + } } else { if TouchscreenControllerEventAdapter.thumbstickCursorControl[name] == nil { - TouchscreenControllerEventAdapter.thumbstickCursorControl[name] = ThumbstickCursorControl(name).update + TouchscreenControllerEventAdapter.thumbstickCursorControl[name] = ThumbstickCursorControl(name) } - TouchscreenControllerEventAdapter.thumbstickCursorControl[name]!(cgDx * 6, cgDy * 6) + TouchscreenControllerEventAdapter.thumbstickCursorControl[name]!.update(velocityX: cgDx, velocityY: cgDy) + } + } + + private func acceleratedViewAxis(_ value: CGFloat) -> CGFloat { + let magnitude = pow(abs(value), 0.75) * 1.35 + return value < 0 ? -magnitude : magnitude + } + + private static func stopThumbstickMotion(named key: String) { + if let control = thumbstickCursorControl[key] { + control.stop() + } else { + _ = ActionDispatcher.dispatch(key: key, valueX: 0, valueY: 0) } } @@ -87,13 +131,24 @@ class ThumbstickCursorControl { } } + public func stop() { + thumbstickVelocity = .zero + _ = ActionDispatcher.dispatch(key: key, valueX: 0, valueY: 0) + } + private func thumbstickPoll() { if !ThumbstickCursorControl.isVectorSignificant(self.thumbstickVelocity) { + _ = ActionDispatcher.dispatch(key: key, valueX: 0, valueY: 0) self.thumbstickPolling = false return } - _ = ActionDispatcher.dispatch(key: key, valueX: thumbstickVelocity.dx, valueY: thumbstickVelocity.dy) + let cameraMapping = ActionDispatcher.getDispatchPriority(key: key) == .CAMERA + let multiplier: CGFloat = cameraMapping ? 3.8 : 6 + _ = ActionDispatcher.dispatch(key: key, + valueX: thumbstickVelocity.dx * multiplier, + valueY: thumbstickVelocity.dy * multiplier) PlayInput.touchQueue.asyncAfter( - deadline: DispatchTime.now() + 0.017, execute: self.thumbstickPoll) + deadline: DispatchTime.now() + (cameraMapping ? 1.0 / 165.0 : 0.017), + execute: self.thumbstickPoll) } } diff --git a/PlayTools/Controls/Frontend/VirtualCursorController.swift b/PlayTools/Controls/Frontend/VirtualCursorController.swift new file mode 100644 index 00000000..f9b632b2 --- /dev/null +++ b/PlayTools/Controls/Frontend/VirtualCursorController.swift @@ -0,0 +1,288 @@ +// +// VirtualCursorController.swift +// PlayTools +// + +import Foundation +import UIKit + +final class VirtualCursorController { + static let shared = VirtualCursorController() + + private let cursorView = VirtualCursorView(frame: CGRect(x: 0, y: 0, width: 64, height: 64)) + private var position = CGPoint.zero + private var velocity = CGVector.zero + private var polling = false + private var previousPollTime: CFTimeInterval? + private var touchId: Int? + private var directionalTouchId: Int? + private var directionalTouchPosition = CGPoint.zero + private var directionalKeys = Set() + private var directionalPolling = false + private var visible = false + private(set) var isActive = false + private var triggerPressed = false + private var pendingDrag: DispatchWorkItem? + + private let innerDeadZone: CGFloat = 0.15 + private let outerDeadZone: CGFloat = 0.05 + private let maximumSpeed: CGFloat = 1_100 + private let pollingInterval = 1.0 / 60.0 + private let dragActivationDelay = 0.22 + private let tapDuration = 0.07 + + private init() {} + + func toggle() { + if isActive { + deactivate() + } else { + activate() + } + } + + private func activate() { + isActive = true + visible = true + if position == .zero { + position = CGPoint(x: screen.width / 2, y: screen.height / 2) + } + let currentPosition = position + DispatchQueue.main.async { + self.attachIfNeeded() + self.cursorView.center = currentPosition + self.cursorView.isHidden = false + } + } + + func update(velocityX: CGFloat, velocityY: CGFloat) { + PlayInput.touchQueue.async { + self.velocity = CGVector(dx: velocityX, dy: -velocityY) + if !self.polling { + self.polling = true + self.poll() + } + } + } + + func handleTrigger(pressed: Bool) { + guard pressed != triggerPressed else { return } + triggerPressed = pressed + + if pressed { + let workItem = DispatchWorkItem { [weak self] in + guard let self, self.isActive, self.triggerPressed else { return } + self.pendingDrag = nil + Toucher.touchcam(point: self.position, phase: .began, tid: &self.touchId, + actionName: "VirtualCursor", keyName: "Left Trigger") + } + pendingDrag = workItem + PlayInput.touchQueue.asyncAfter(deadline: .now() + dragActivationDelay, + execute: workItem) + } else if let pendingDrag { + pendingDrag.cancel() + self.pendingDrag = nil + performTap() + } else if touchId != nil { + Toucher.touchcam(point: position, phase: .ended, tid: &touchId, + actionName: "VirtualCursor", keyName: "Left Trigger") + } + } + + private func performTap() { + Toucher.touchcam(point: position, phase: .began, tid: &touchId, + actionName: "VirtualCursor", keyName: "Left Trigger") + PlayInput.touchQueue.asyncAfter(deadline: .now() + tapDuration) { + Toucher.touchcam(point: self.position, phase: .ended, tid: &self.touchId, + actionName: "VirtualCursor", keyName: "Left Trigger") + } + } + + func handleDirectionalDrag(key: String, pressed: Bool) { + PlayInput.touchQueue.async { + if pressed { + self.directionalKeys.insert(key) + } else { + self.directionalKeys.remove(key) + } + + if self.directionalKeys.isEmpty { + self.endDirectionalDrag() + } else if self.directionalTouchId == nil, self.touchId == nil { + self.beginDirectionalDrag(keyName: key) + if !self.directionalPolling { + self.directionalPolling = true + self.pollDirectionalDrag() + } + } + } + } + + func deactivate(at newPosition: CGPoint? = nil) { + if let newPosition { + position = newPosition + } + isActive = false + visible = false + triggerPressed = false + pendingDrag?.cancel() + pendingDrag = nil + if touchId != nil { + Toucher.touchcam(point: position, phase: .ended, tid: &touchId, + actionName: "VirtualCursor", keyName: "Left Trigger") + } + directionalKeys.removeAll() + endDirectionalDrag() + DispatchQueue.main.async { + self.cursorView.isHidden = true + } + } + + private func poll() { + guard isActive else { + polling = false + previousPollTime = nil + return + } + let vectorMagnitude = hypot(velocity.dx, velocity.dy) + guard vectorMagnitude > innerDeadZone else { + polling = false + previousPollTime = nil + return + } + + let now = CACurrentMediaTime() + let elapsed = previousPollTime.map { now - $0 } ?? pollingInterval + previousPollTime = now + let deltaTime = CGFloat(min(max(elapsed, 0), 1.0 / 30.0)) + + // Preserve the stick direction while remapping the usable radial range linearly. + // Full tilt therefore always means maximumSpeed, and half travel is predictable. + let usableRange = 1 - innerDeadZone - outerDeadZone + let cappedMagnitude = min(vectorMagnitude, 1 - outerDeadZone) + let linearMagnitude = (cappedMagnitude - innerDeadZone) / usableRange + let distance = maximumSpeed * linearMagnitude * deltaTime + let movement = CGVector(dx: velocity.dx / vectorMagnitude * distance, + dy: velocity.dy / vectorMagnitude * distance) + + position.x = min(max(position.x + movement.dx, 0), screen.width) + position.y = min(max(position.y + movement.dy, 0), screen.height) + visible = true + + if touchId != nil { + Toucher.touchcam(point: position, phase: .moved, tid: &touchId, + actionName: "VirtualCursor", keyName: "Left Trigger") + } + + let nextPosition = position + DispatchQueue.main.async { + self.attachIfNeeded() + self.cursorView.center = nextPosition + self.cursorView.isHidden = false + } + + PlayInput.touchQueue.asyncAfter(deadline: .now() + pollingInterval, execute: poll) + } + + private func pollDirectionalDrag() { + guard isActive, !directionalKeys.isEmpty, directionalTouchId != nil else { + endDirectionalDrag() + return + } + + let direction = directionalVector() + let magnitude = hypot(direction.dx, direction.dy) + guard magnitude > 0 else { + endDirectionalDrag() + return + } + + let speed: CGFloat = 8 + let point = CGPoint(x: directionalTouchPosition.x + direction.dx / magnitude * speed, + y: directionalTouchPosition.y + direction.dy / magnitude * speed) + let edgeMargin: CGFloat = 12 + if point.x <= edgeMargin || point.x >= screen.width - edgeMargin || + point.y <= edgeMargin || point.y >= screen.height - edgeMargin { + endDirectionalDrag() + PlayInput.touchQueue.asyncAfter(deadline: .now() + 0.04) { + guard self.isActive, !self.directionalKeys.isEmpty, self.touchId == nil else { return } + self.beginDirectionalDrag(keyName: "Direction Pad") + self.directionalPolling = true + self.pollDirectionalDrag() + } + return + } + directionalTouchPosition = point + Toucher.touchcam(point: point, phase: .moved, tid: &directionalTouchId, + actionName: "VirtualCursorDirectionalDrag", keyName: "Direction Pad") + PlayInput.touchQueue.asyncAfter(deadline: .now() + 0.017, execute: pollDirectionalDrag) + } + + private func directionalVector() -> CGVector { + var vector = CGVector.zero + if directionalKeys.contains("Direction Pad Up") { vector.dy += 1 } + if directionalKeys.contains("Direction Pad Down") { vector.dy -= 1 } + if directionalKeys.contains("Direction Pad Left") { vector.dx += 1 } + if directionalKeys.contains("Direction Pad Right") { vector.dx -= 1 } + return vector + } + + private func endDirectionalDrag() { + if directionalTouchId != nil { + Toucher.touchcam(point: directionalTouchPosition, phase: .ended, tid: &directionalTouchId, + actionName: "VirtualCursorDirectionalDrag", keyName: "Direction Pad") + } + directionalPolling = false + } + + private func beginDirectionalDrag(keyName: String) { + let direction = directionalVector() + let resetDistance: CGFloat = 96 + directionalTouchPosition = position + if direction.dx < 0 { + directionalTouchPosition.x = max(directionalTouchPosition.x, resetDistance) + } else if direction.dx > 0 { + directionalTouchPosition.x = min(directionalTouchPosition.x, screen.width - resetDistance) + } + if direction.dy < 0 { + directionalTouchPosition.y = max(directionalTouchPosition.y, resetDistance) + } else if direction.dy > 0 { + directionalTouchPosition.y = min(directionalTouchPosition.y, screen.height - resetDistance) + } + Toucher.touchcam(point: directionalTouchPosition, phase: .began, tid: &directionalTouchId, + actionName: "VirtualCursorDirectionalDrag", keyName: keyName) + } + + private func attachIfNeeded() { + guard let rootView = screen.keyWindow?.rootViewController?.view else { return } + if cursorView.superview !== rootView { + cursorView.removeFromSuperview() + rootView.addSubview(cursorView) + } + rootView.bringSubviewToFront(cursorView) + } +} + +private final class VirtualCursorView: UIView { + private let dot = UIView(frame: CGRect(x: 18, y: 18, width: 28, height: 28)) + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + backgroundColor = .clear + + dot.backgroundColor = UIColor.white.withAlphaComponent(0.82) + dot.layer.cornerRadius = 14 + dot.layer.borderWidth = 3 + dot.layer.borderColor = UIColor.black.withAlphaComponent(0.72).cgColor + dot.layer.shadowColor = UIColor.black.cgColor + dot.layer.shadowOpacity = 0.65 + dot.layer.shadowRadius = 10 + dot.layer.shadowOffset = .zero + addSubview(dot) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/PlayTools/Controls/MenuController.swift b/PlayTools/Controls/MenuController.swift index 6d734d1e..d2f132b3 100644 --- a/PlayTools/Controls/MenuController.swift +++ b/PlayTools/Controls/MenuController.swift @@ -30,6 +30,11 @@ extension UIApplication { ModeAutomaton.onCmdK() } + @objc + func toggleKeymapHUD(_ sender: AnyObject) { + EditorController.shared.toggleHUD() + } + @objc func removeElement(_ sender: AnyObject) { EditorController.shared.removeControl() @@ -45,6 +50,42 @@ extension UIApplication { EditorController.shared.focusedControl?.resize(down: true) } + @objc + func captureElementModifierKey(_ sender: AnyObject) { + EditorController.shared.captureModifierKey() + } + + @objc + func clearElementModifierKey(_ sender: AnyObject) { + EditorController.shared.clearModifierKey() + } + + @objc + func cycleSwipeDirection(_ sender: AnyObject) { + EditorController.shared.cycleSwipeDirection() + } + + @objc + func toggleElementHoldTrigger(_ sender: AnyObject) { + EditorController.shared.toggleHoldTrigger() + } + + @objc + func toggleShoulderKeymapSwitch(_ sender: AnyObject) { + ShoulderKeymapSwitchAction.isEnabled.toggle() + let titleKey = ShoulderKeymapSwitchAction.isEnabled + ? "hint.keymapping.shoulderSwitch.enabled" + : "hint.keymapping.shoulderSwitch.disabled" + let fallback = ShoulderKeymapSwitchAction.isEnabled + ? "Shoulder keymap switch enabled" + : "Shoulder keymap switch disabled" + Toast.showHint(title: NSLocalizedString(titleKey, + tableName: "Playtools", + value: fallback, + comment: "")) + ActionDispatcher.build() + } + // put a mark in the toucher log, so as to align with tester description @objc func markToucherLog(_ sender: AnyObject) { @@ -88,9 +129,7 @@ extension UIApplication { ModeAutomaton.onCmdK() } - keymap.nextKeymap() - Toast.showHint(title: "Switched to next keymap: \(keymap.currentKeymapName)") - ActionDispatcher.build() + KeymapSwitcher.switchKeymap(.next) if isCmdK { ModeAutomaton.onCmdK() @@ -105,9 +144,7 @@ extension UIApplication { ModeAutomaton.onCmdK() } - keymap.previousKeymap() - Toast.showHint(title: "Switched to previous keymap: \(keymap.currentKeymapName)") - ActionDispatcher.build() + KeymapSwitcher.switchKeymap(.previous) if isCmdK { ModeAutomaton.onCmdK() @@ -135,12 +172,24 @@ struct CommandsList { var keymapping = [ NSLocalizedString("menu.keymapping.toggleEditor", tableName: "Playtools", value: "Open/Close Keymapping Editor", comment: ""), + NSLocalizedString("menu.keymapping.toggleHUD", tableName: "Playtools", + value: "Show/Hide Keymap HUD", comment: ""), NSLocalizedString("menu.keymapping.deleteElement", tableName: "Playtools", value: "Delete selected element", comment: ""), NSLocalizedString("menu.keymapping.upsizeElement", tableName: "Playtools", value: "Upsize selected element", comment: ""), NSLocalizedString("menu.keymapping.downsizeElement", tableName: "Playtools", value: "Downsize selected element", comment: ""), + NSLocalizedString("menu.keymapping.captureModifier", tableName: "Playtools", + value: "Set selected button modifier", comment: ""), + NSLocalizedString("menu.keymapping.clearModifier", tableName: "Playtools", + value: "Clear selected button modifier", comment: ""), + NSLocalizedString("menu.keymapping.cycleSwipeDirection", tableName: "Playtools", + value: "Switch selected swipe direction", comment: ""), + NSLocalizedString("menu.keymapping.toggleHoldTrigger", tableName: "Playtools", + value: "Toggle selected long-press trigger", comment: ""), + NSLocalizedString("menu.keymapping.toggleShoulderKeymapSwitch", tableName: "Playtools", + value: "Toggle shoulder keymap switch", comment: ""), NSLocalizedString("menu.keymapping.toggleDebug", tableName: "Playtools", value: "Toggle Debug Overlay", comment: ""), NSLocalizedString("menu.keymapping.hide.pointer", tableName: "Playtools", @@ -152,20 +201,30 @@ var keymapping = [ ] var iconsSelctor = [ UIImage(systemName: "pencil"), + UIImage(systemName: "eye"), UIImage(systemName: "trash.fill"), UIImage(systemName: "square.resize.up"), UIImage(systemName: "square.resize.down"), - UIImage(systemName: "rectangle.landscape.rotate"), + UIImage(systemName: "command"), + UIImage(systemName: "command.circle"), + UIImage(systemName: "arrow.triangle.2.circlepath"), + UIImage(systemName: "timer"), + UIImage(systemName: "gamecontroller"), UIImage(systemName: "wrench.and.screwdriver"), UIImage(systemName: "pointer.arrow.slash"), UIImage(systemName: "arrow.down.square"), UIImage(systemName: "arrow.up.square") ] var keymappingSelectors = [#selector(UIApplication.switchEditorMode(_:)), + #selector(UIApplication.toggleKeymapHUD(_:)), #selector(UIApplication.removeElement(_:)), #selector(UIApplication.upscaleElement(_:)), #selector(UIApplication.downscaleElement(_:)), - #selector(UIApplication.rotateView(_:)), + #selector(UIApplication.captureElementModifierKey(_:)), + #selector(UIApplication.clearElementModifierKey(_:)), + #selector(UIApplication.cycleSwipeDirection(_:)), + #selector(UIApplication.toggleElementHoldTrigger(_:)), + #selector(UIApplication.toggleShoulderKeymapSwitch(_:)), #selector(UIApplication.toggleDebugOverlay(_:)), #selector(UIApplication.hideCursor(_:)), #selector(UIApplication.previousKeymap(_:)), @@ -232,20 +291,43 @@ class MenuController { class func keymappingMenu() -> UIMenu { let keyCommands = [ "K", // Toggle keymap editor + "K", // Toggle keymap HUD UIKeyCommand.inputDelete, // Remove keymap element UIKeyCommand.inputUpArrow, // Increase keymap element size UIKeyCommand.inputDownArrow, // Decrease keymap element size + "M", // Set button modifier + "M", // Clear button modifier + "R", // Switch swipe direction + "L", // Toggle long-press trigger + "B", // Toggle shoulder keymap switch "D", // Toggle debug overlay ".", // Hide cursor until move "[", // Previous keymap "]" // Next keymap ] - let arrowKeyChildrenCommands = zip(zip(keyCommands, keymapping), iconsSelctor).map { (arg0, image) in - let (command, btn) = arg0 + let keyModifiers: [UIKeyModifierFlags] = [ + .command, + [.command, .shift], + .command, + .command, + .command, + .command, + [.command, .shift], + .command, + .command, + [.command, .shift], + .command, + .command, + .command, + .command + ] + let arrowKeyChildrenCommands = zip(zip(zip(keyCommands, keymapping), iconsSelctor), keyModifiers) + .map { (arg0, modifierFlags) in + let ((command, btn), image) = arg0 return UIKeyCommand(title: btn, image: image, action: keymappingSelectors[keymapping.firstIndex(of: btn)!], input: command, - modifierFlags: .command, + modifierFlags: modifierFlags, propertyList: [CommandsList.KeymappingToolbox: btn] ) } diff --git a/PlayTools/Editor/Controllers/EditorController.swift b/PlayTools/Editor/Controllers/EditorController.swift index c950124f..61c13917 100644 --- a/PlayTools/Editor/Controllers/EditorController.swift +++ b/PlayTools/Editor/Controllers/EditorController.swift @@ -3,15 +3,23 @@ import SwiftUI let editor = EditorController.shared +// swiftlint:disable type_body_length class EditorController { static let shared = EditorController() + private enum KeyCaptureMode { + case primary + case modifier + } + let lock = NSLock() var focusedControl: ControlElement? + private var keyCaptureMode = KeyCaptureMode.primary var editorWindow: UIWindow? + private var hudWindow: KeymapHUDWindow? weak var previousWindow: UIWindow? var controls: [ControlElement] = [] var view: EditorView! {editorWindow?.rootViewController?.view as? EditorView} @@ -25,6 +33,9 @@ class EditorController { private func addControlToView(control: ControlElement) { controls.append(control) view.addSubview(control.button) + if let radialSelector = control as? RadialSelectorModel { + radialSelector.slotControls.forEach { view.addSubview($0.button) } + } updateFocus(button: control.button) } @@ -38,6 +49,7 @@ class EditorController { if let mod = button.model { mod.focus(true) focusedControl = mod + keyCaptureMode = .primary } } @@ -53,6 +65,7 @@ class EditorController { editorWindow = nil previousWindow?.makeKeyAndVisible() focusedControl = nil + keyCaptureMode = .primary Toast.showHint(title: NSLocalizedString("hint.keymapSaved", tableName: "Playtools", value: "Keymap Saved", comment: "")) } else { @@ -73,28 +86,200 @@ class EditorController { } var editorMode: Bool { !(editorWindow?.isHidden ?? true)} + var hudVisible: Bool { !(hudWindow?.isHidden ?? true)} + + public func toggleHUD() { + if hudVisible { + hideHUD() + } else { + showHUD() + } + } + + public func refreshHUD() { + guard hudVisible else { + return + } + hudWindow?.rootViewController?.view = KeymapHUDView(map: keymap.currentKeymap) + } + + private func showHUD() { + guard let windowScene = screen.windowScene else { + return + } + let window = KeymapHUDWindow(windowScene: windowScene) + window.rootViewController?.view = KeymapHUDView(map: keymap.currentKeymap) + window.isHidden = false + hudWindow = window + Toast.showHint(title: NSLocalizedString("hint.keymappingHUD.shown", + tableName: "Playtools", + value: "Keymap HUD shown", + comment: "")) + } + + private func hideHUD() { + hudWindow?.isHidden = true + hudWindow?.windowScene = nil + hudWindow?.rootViewController = nil + hudWindow = nil + Toast.showHint(title: NSLocalizedString("hint.keymappingHUD.hidden", + tableName: "Playtools", + value: "Keymap HUD hidden", + comment: "")) + } public func setKey(_ code: Int) { if editorMode { - focusedControl?.setKey(code: code) + if keyCaptureMode == .modifier { + focusedControl?.setModifierKey(code: code) + finishModifierCapture() + } else { + focusedControl?.setKey(code: code) + } } } public func setKey(_ name: String) { if editorMode { + if keyCaptureMode == .modifier { + focusedControl?.setModifierKey(name: name) + finishModifierCapture() + return + } if name != "Mouse" || focusedControl is MouseAreaModel || focusedControl is JoystickModel - || focusedControl is DraggableButtonModel { + || focusedControl is DraggableButtonModel + || focusedControl is RadialSelectorModel + || focusedControl is RadialSelectorSlotControl { focusedControl?.setKey(name: name) } } } + public func captureModifierKey() { + guard editorMode, + focusedControl is ButtonModel + || focusedControl is SwipeModel + || focusedControl is RadialSelectorModel + || focusedControl is RadialSelectorSlotControl else { + Toast.showHint( + title: NSLocalizedString("hint.keymappingEditor.selectButton.title", + tableName: "Playtools", + value: "Select a button first", + comment: ""), + text: [NSLocalizedString("hint.keymappingEditor.selectButton.captureModifier.content", + tableName: "Playtools", + value: "Select a button or swipe mapping, then press ⌘M " + + "to set its modifier key.", + comment: "")] + ) + return + } + keyCaptureMode = .modifier + Toast.showHint( + title: NSLocalizedString("hint.keymappingEditor.captureModifier.title", + tableName: "Playtools", + value: "Press modifier key", + comment: ""), + text: [NSLocalizedString("hint.keymappingEditor.captureModifier.content", + tableName: "Playtools", + value: "The next keyboard, mouse, or controller button will become the modifier.", + comment: "")] + ) + } + + public func clearModifierKey() { + guard editorMode, + focusedControl is ButtonModel + || focusedControl is DraggableButtonModel + || focusedControl is SwipeModel + || focusedControl is RadialSelectorModel + || focusedControl is RadialSelectorSlotControl else { + Toast.showHint( + title: NSLocalizedString("hint.keymappingEditor.selectButton.title", + tableName: "Playtools", + value: "Select a button first", + comment: ""), + text: [NSLocalizedString("hint.keymappingEditor.selectButton.clearModifier.content", + tableName: "Playtools", + value: "Select a button or swipe mapping, then press ⌘⇧M " + + "to clear its modifier key.", + comment: "")] + ) + return + } + focusedControl?.clearModifierKey() + keyCaptureMode = .primary + Toast.showHint(title: NSLocalizedString("hint.keymappingEditor.modifierCleared.title", + tableName: "Playtools", + value: "Modifier cleared", + comment: "")) + } + + public func toggleHoldTrigger() { + guard editorMode, + focusedControl is ButtonModel + || focusedControl is DraggableButtonModel + || focusedControl is SwipeModel + || focusedControl is RadialSelectorModel + || focusedControl is RadialSelectorSlotControl else { + Toast.showHint(title: NSLocalizedString("hint.keymappingEditor.selectHoldTrigger.title", + tableName: "Playtools", + value: "Select a button, swipe, or radial selector first", + comment: "")) + return + } + focusedControl?.toggleHoldTrigger() + keyCaptureMode = .primary + Toast.showHint(title: NSLocalizedString("hint.keymappingEditor.holdTriggerToggled.title", + tableName: "Playtools", + value: "Long-press trigger toggled", + comment: "")) + } + + private func finishModifierCapture() { + keyCaptureMode = .primary + Toast.showHint( + title: NSLocalizedString("hint.keymappingEditor.modifierSet.title", + tableName: "Playtools", + value: "Modifier set", + comment: ""), + text: [NSLocalizedString("hint.keymappingEditor.modifierSet.content", + tableName: "Playtools", + value: "Press another key to change the button's main binding.", + comment: "")] + ) + } + public func removeControl() { + keyCaptureMode = .primary + if let radialSlot = focusedControl as? RadialSelectorSlotControl { + controls = controls.filter { $0 !== radialSlot.parent } + radialSlot.parent.remove() + focusedControl = nil + return + } controls = controls.filter { $0 !== focusedControl } focusedControl?.remove() } + public func cycleSwipeDirection() { + guard editorMode, focusedControl is SwipeModel else { + Toast.showHint( + title: NSLocalizedString("hint.keymappingEditor.selectSwipe.title", + tableName: "Playtools", + value: "Select a swipe first", + comment: ""), + text: [NSLocalizedString("hint.keymappingEditor.selectSwipe.cycleDirection.content", + tableName: "Playtools", + value: "Select a swipe mapping, then press ⌘R to change its direction.", + comment: "")] + ) + return + } + focusedControl?.cycleDirection() + } + func showButtons() { for button in keymap.currentKeymap.draggableButtonModels { let ctrl = DraggableButtonModel(data: button) @@ -109,6 +294,14 @@ class EditorController { MouseAreaModel(data: mouse) addControlToView(control: ctrl) } + for swipe in keymap.currentKeymap.swipeModels { + let ctrl = SwipeModel(data: swipe) + addControlToView(control: ctrl) + } + for radialSelector in keymap.currentKeymap.radialSelectorModels { + let ctrl = RadialSelectorModel(data: radialSelector) + addControlToView(control: ctrl) + } for button in keymap.currentKeymap.buttonModels { let ctrl = ButtonModel(data: button) addControlToView(control: ctrl) @@ -126,6 +319,10 @@ class EditorController { keymapData.draggableButtonModels.append(model.save()) case let model as MouseAreaModel: keymapData.mouseAreaModel.append(model.save()) + case let model as SwipeModel: + keymapData.swipeModels.append(model.save()) + case let model as RadialSelectorModel: + keymapData.radialSelectorModels.append(model.save()) case let model as ButtonModel: keymapData.buttonModels.append(model.save()) default: @@ -204,6 +401,36 @@ class EditorController { } } + public func addSwipe(_ center: CGPoint) { + if editorMode { + addControlToView(control: SwipeModel(data: Swipe( + keyCode: -1, + keyName: KeyCodeNames.leftMouseButton, + transform: KeyModelTransform( + size: 12, xCoord: center.x.relativeX, yCoord: center.y.relativeY + ), + angle: CGFloat.pi * 3 / 2 + ))) + } + } + + public func addRadialSelector(_ center: CGPoint) { + if editorMode { + let size = CGFloat(12) + addControlToView(control: RadialSelectorModel(data: RadialSelector( + keyCode: KeyCodeNames.defaultCode, + keyName: "Right Thumbstick", + modifierKeyCode: -12, + modifierKeyName: "Left Shoulder", + transform: KeyModelTransform( + size: size, xCoord: center.x.relativeX, yCoord: center.y.relativeY + ), + activationThreshold: 0.35, + slots: RadialSelectorModel.makeDefaultSlots(center: center, size: size.absoluteSize) + ))) + } + } + public func addDraggableButton(_ center: CGPoint, _ keyCode: Int) { if editorMode { addControlToView(control: DraggableButtonModel(data: Button( @@ -220,3 +447,4 @@ class EditorController { view.label?.text = str } } +// swiftlint:enable type_body_length diff --git a/PlayTools/Editor/Controllers/Elements/ElementController.swift b/PlayTools/Editor/Controllers/Elements/ElementController.swift index a289d2d4..6821f0cb 100644 --- a/PlayTools/Editor/Controllers/Elements/ElementController.swift +++ b/PlayTools/Editor/Controllers/Elements/ElementController.swift @@ -13,6 +13,11 @@ protocol ControlElement: AnyObject { func focus(_ focus: Bool) func setKey(code: Int) func setKey(name: String) + func setModifierKey(code: Int) + func setModifierKey(name: String) + func clearModifierKey() + func cycleDirection() + func toggleHoldTrigger() func resize(down: Bool) func remove() } @@ -71,4 +76,14 @@ class ControlModel: ControlElement { func setKey(name: String) { self.setKey(code: KeyCodeNames.defaultCode, name: name) } + + func setModifierKey(code: Int) {} + + func setModifierKey(name: String) {} + + func clearModifierKey() {} + + func cycleDirection() {} + + func toggleHoldTrigger() {} } diff --git a/PlayTools/Editor/Controllers/Elements/Instances/Button.swift b/PlayTools/Editor/Controllers/Elements/Instances/Button.swift index 4e4d6c6e..d6258590 100644 --- a/PlayTools/Editor/Controllers/Elements/Instances/Button.swift +++ b/PlayTools/Editor/Controllers/Elements/Instances/Button.swift @@ -8,6 +8,21 @@ import Foundation class ButtonModel: ControlModel