diff --git a/PlayTools.xcodeproj/project.pbxproj b/PlayTools.xcodeproj/project.pbxproj index 555e944e..70ef16d2 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 */; }; @@ -118,6 +119,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 = ""; }; @@ -237,6 +239,7 @@ AA71978C287A481500623C15 /* CircleMenu */, AA71979D287A481500623C15 /* EditorCircleMenu.swift */, 954389CB2B39F03D00B063BB /* EditorView.swift */, + 954389D52B3A6CD000B063BB /* KeymapHUDView.swift */, ); path = Views; sourceTree = ""; @@ -727,6 +730,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 */, diff --git a/PlayTools/Controls/ActionDispatcher.swift b/PlayTools/Controls/ActionDispatcher.swift index 000cf1a1..41fa54d7 100644 --- a/PlayTools/Controls/ActionDispatcher.swift +++ b/PlayTools/Controls/ActionDispatcher.swift @@ -20,11 +20,12 @@ 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 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 +36,7 @@ public class ActionDispatcher { invalidateActions() actions = [] buttonHandlers.removeAll(keepingCapacity: true) + pressedKeys.removeAll(keepingCapacity: true) directionPadHandlers.forEach({ handlers in handlers.forEach({ handler in handler.store(.EMPTY, ordering: .relaxed) @@ -49,6 +51,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 +84,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 +109,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 +146,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 +202,100 @@ 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 { + if pressed { + pressedKeys.insert(key) + } else { + pressedKeys.remove(key) + } 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 { + keys.contains { pressedKeys.contains($0) } } 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.. 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