diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 00000000..756b081e --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,10 @@ +## 2024-06-03 - Missing tooltips and accessibility on list item actions +**Learning:** Icon-only buttons used for item actions (like 'Edit', 'Delete', 'Remove') within list views often lack tooltips and accessibility labels. This omission makes the UI ambiguous for mouse users on macOS and inaccessible to screen readers. +**Action:** Always add `.help("Action Name")` and `.accessibilityLabel("Action Name Item Type")` to icon-only buttons, especially those located at the trailing edge of list rows. + +## 2024-11-20 - Ensure Symmetry in Accessibility and Tooltips for SwiftUI Icon Buttons +**Learning:** In macOS SwiftUI applications, icon-only buttons often have either `.help()` (for hover tooltips) or `.accessibilityLabel()` (for VoiceOver), but frequently lack both. Both are necessary because they serve different user interaction models—`.help` for visual hover feedback and `.accessibilityLabel` for screen readers. +**Action:** When adding or reviewing icon-only buttons in SwiftUI, always ensure symmetry by defining both `.help("Description")` and `.accessibilityLabel("Description")` to cover all accessibility and usability vectors. +## 2026-06-06 - Ensure Symmetry in Accessibility and Tooltips for SwiftUI Icon Buttons +**Learning:** In macOS SwiftUI applications, icon-only buttons often have either `.help()` (for hover tooltips) or `.accessibilityLabel()` (for VoiceOver), but frequently lack both. Both are necessary because they serve different user interaction models—`.help` for visual hover feedback and `.accessibilityLabel` for screen readers. +**Action:** When adding or reviewing icon-only buttons in SwiftUI, always ensure symmetry by defining both `.help("Description")` and `.accessibilityLabel("Description")` to cover all accessibility and usability vectors. diff --git a/LICENSE b/LICENSE index ba9cdd15..724b8545 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ ControllerKeys - Source Available License -Copyright (c) 2024-2025 Kevin Tang +Copyright (c) 2025-2026 Kevin Tang TERMS AND CONDITIONS diff --git a/XboxControllerMapper/XboxControllerMapper/Models/CommandWheelAction.swift b/XboxControllerMapper/XboxControllerMapper/Models/CommandWheelAction.swift index 59efad11..a6bbf376 100644 --- a/XboxControllerMapper/XboxControllerMapper/Models/CommandWheelAction.swift +++ b/XboxControllerMapper/XboxControllerMapper/Models/CommandWheelAction.swift @@ -146,7 +146,7 @@ struct CommandWheelAction: Codable, Identifiable, Equatable, ExecutableAction { return NSWorkspace.shared.icon(forFile: url.path) } case .openLink(let url): - if let data = FaviconCache.shared.loadCachedFavicon(for: url) { + if let data = FaviconCache.shared.memoryCachedFavicon(for: url) { return NSImage(data: data) } default: diff --git a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButton.swift b/XboxControllerMapper/XboxControllerMapper/Models/ControllerButton.swift index 06104bad..af43e6c2 100644 --- a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButton.swift +++ b/XboxControllerMapper/XboxControllerMapper/Models/ControllerButton.swift @@ -448,88 +448,92 @@ enum ControllerButton: String, Codable, CaseIterable, Identifiable, Sendable { /// SF Symbol name appropriate for the controller type func systemImageName(forDualSense isDualSense: Bool) -> String? { - if isDualSense { - switch self { - case .dpadUp: return "arrowtriangle.up.fill" - case .dpadDown: return "arrowtriangle.down.fill" - case .dpadLeft: return "arrowtriangle.left.fill" - case .dpadRight: return "arrowtriangle.right.fill" - case .leftStickUp, .rightStickUp: return "arrow.up.circle" - case .leftStickDown, .rightStickDown: return "arrow.down.circle" - case .leftStickLeft, .rightStickLeft: return "arrow.left.circle" - case .leftStickRight, .rightStickRight: return "arrow.right.circle" - case .leftStickUpLeft, .rightStickUpLeft: return "arrow.up.left.circle" - case .leftStickUpRight, .rightStickUpRight: return "arrow.up.right.circle" - case .leftStickDownLeft, .rightStickDownLeft: return "arrow.down.left.circle" - case .leftStickDownRight, .rightStickDownRight: return "arrow.down.right.circle" - case .menu: return "line.3.horizontal" - case .view: return "square.and.arrow.up" // Create/Share button (upload icon) - case .share: return "square.and.arrow.up" - case .xbox: return "playstation.logo" // PS button - case .leftThumbstick: return "l3.button.angledbottom.horizontal.left" - case .rightThumbstick: return "r3.button.angledbottom.horizontal.right" - case .touchpadButton: return "hand.point.up.left" - case .touchpadTwoFingerButton: return nil // Use text label "2P" - case .touchpadTap: return "hand.tap" - case .touchpadTwoFingerTap: return "hand.tap" - case .micMute: return "mic.slash" - case .leftTouchpadButton, .rightTouchpadButton: return "hand.point.up.left" - case .leftTouchpadTap, .rightTouchpadTap: return "hand.tap" - case .touchpadRegionTopLeftClick, .touchpadRegionTopLeftTouch, - .touchpadRegionTopRightClick, .touchpadRegionTopRightTouch, - .touchpadRegionBottomLeftClick, .touchpadRegionBottomLeftTouch, - .touchpadRegionBottomRightClick, .touchpadRegionBottomRightTouch: - // Custom 2×2 quadrant indicator drawn by ButtonIconView; no - // SF Symbol used for these. - return nil - case .leftPaddle: return "l.button.roundedbottom.horizontal" - case .rightPaddle: return "r.button.roundedbottom.horizontal" - case .leftFunction: return "button.horizontal.top.press" - case .rightFunction: return "button.horizontal.top.press" - // Face buttons use text symbols for DualSense, not SF Symbols - case .a, .b, .x, .y: return nil - default: return nil - } - } else { - switch self { - case .dpadUp: return "arrowtriangle.up.fill" - case .dpadDown: return "arrowtriangle.down.fill" - case .dpadLeft: return "arrowtriangle.left.fill" - case .dpadRight: return "arrowtriangle.right.fill" - case .leftStickUp, .rightStickUp: return "arrow.up.circle" - case .leftStickDown, .rightStickDown: return "arrow.down.circle" - case .leftStickLeft, .rightStickLeft: return "arrow.left.circle" - case .leftStickRight, .rightStickRight: return "arrow.right.circle" - case .leftStickUpLeft, .rightStickUpLeft: return "arrow.up.left.circle" - case .leftStickUpRight, .rightStickUpRight: return "arrow.up.right.circle" - case .leftStickDownLeft, .rightStickDownLeft: return "arrow.down.left.circle" - case .leftStickDownRight, .rightStickDownRight: return "arrow.down.right.circle" - case .menu: return "line.3.horizontal" - case .view: return "rectangle.on.rectangle" - case .share: return "square.and.arrow.up" - case .xbox: return "xbox.logo" - case .siri: return "mic.fill" - case .appleTVRemotePower: return "power" - case .appleTVRemoteVolumeUp: return "speaker.wave.3.fill" - case .appleTVRemoteVolumeDown: return "speaker.wave.1.fill" - case .appleTVRemoteMute: return "speaker.slash.fill" - case .leftThumbstick: return "l.circle" - case .rightThumbstick: return "r.circle" - case .a: return "a.circle" - case .b: return "b.circle" - case .x: return "x.circle" - case .y: return "y.circle" - case .touchpadButton: return "hand.point.up.left" - case .touchpadTap: return "hand.tap" - case .leftTouchpadButton, .rightTouchpadButton: return "hand.point.up.left" - case .leftTouchpadTap, .rightTouchpadTap: return "hand.tap" - case .micMute: return "mic.slash" - case .xboxPaddle1: return "l.button.roundedbottom.horizontal" - case .xboxPaddle2: return "r.button.roundedbottom.horizontal" - case .xboxPaddle3: return "l.button.roundedbottom.horizontal.fill" - case .xboxPaddle4: return "r.button.roundedbottom.horizontal.fill" - default: return nil - } + return isDualSense ? dualSenseSystemImageName : xboxSystemImageName + } + + private var dualSenseSystemImageName: String? { + switch self { + case .dpadUp: return "arrowtriangle.up.fill" + case .dpadDown: return "arrowtriangle.down.fill" + case .dpadLeft: return "arrowtriangle.left.fill" + case .dpadRight: return "arrowtriangle.right.fill" + case .leftStickUp, .rightStickUp: return "arrow.up.circle" + case .leftStickDown, .rightStickDown: return "arrow.down.circle" + case .leftStickLeft, .rightStickLeft: return "arrow.left.circle" + case .leftStickRight, .rightStickRight: return "arrow.right.circle" + case .leftStickUpLeft, .rightStickUpLeft: return "arrow.up.left.circle" + case .leftStickUpRight, .rightStickUpRight: return "arrow.up.right.circle" + case .leftStickDownLeft, .rightStickDownLeft: return "arrow.down.left.circle" + case .leftStickDownRight, .rightStickDownRight: return "arrow.down.right.circle" + case .menu: return "line.3.horizontal" + case .view: return "square.and.arrow.up" // Create/Share button (upload icon) + case .share: return "square.and.arrow.up" + case .xbox: return "playstation.logo" // PS button + case .leftThumbstick: return "l3.button.angledbottom.horizontal.left" + case .rightThumbstick: return "r3.button.angledbottom.horizontal.right" + case .touchpadButton: return "hand.point.up.left" + case .touchpadTwoFingerButton: return nil // Use text label "2P" + case .touchpadTap: return "hand.tap" + case .touchpadTwoFingerTap: return "hand.tap" + case .micMute: return "mic.slash" + case .leftTouchpadButton, .rightTouchpadButton: return "hand.point.up.left" + case .leftTouchpadTap, .rightTouchpadTap: return "hand.tap" + case .touchpadRegionTopLeftClick, .touchpadRegionTopLeftTouch, + .touchpadRegionTopRightClick, .touchpadRegionTopRightTouch, + .touchpadRegionBottomLeftClick, .touchpadRegionBottomLeftTouch, + .touchpadRegionBottomRightClick, .touchpadRegionBottomRightTouch: + // Custom 2×2 quadrant indicator drawn by ButtonIconView; no + // SF Symbol used for these. + return nil + case .leftPaddle: return "l.button.roundedbottom.horizontal" + case .rightPaddle: return "r.button.roundedbottom.horizontal" + case .leftFunction: return "button.horizontal.top.press" + case .rightFunction: return "button.horizontal.top.press" + // Face buttons use text symbols for DualSense, not SF Symbols + case .a, .b, .x, .y: return nil + default: return nil + } + } + + private var xboxSystemImageName: String? { + switch self { + case .dpadUp: return "arrowtriangle.up.fill" + case .dpadDown: return "arrowtriangle.down.fill" + case .dpadLeft: return "arrowtriangle.left.fill" + case .dpadRight: return "arrowtriangle.right.fill" + case .leftStickUp, .rightStickUp: return "arrow.up.circle" + case .leftStickDown, .rightStickDown: return "arrow.down.circle" + case .leftStickLeft, .rightStickLeft: return "arrow.left.circle" + case .leftStickRight, .rightStickRight: return "arrow.right.circle" + case .leftStickUpLeft, .rightStickUpLeft: return "arrow.up.left.circle" + case .leftStickUpRight, .rightStickUpRight: return "arrow.up.right.circle" + case .leftStickDownLeft, .rightStickDownLeft: return "arrow.down.left.circle" + case .leftStickDownRight, .rightStickDownRight: return "arrow.down.right.circle" + case .menu: return "line.3.horizontal" + case .view: return "rectangle.on.rectangle" + case .share: return "square.and.arrow.up" + case .xbox: return "xbox.logo" + case .siri: return "mic.fill" + case .appleTVRemotePower: return "power" + case .appleTVRemoteVolumeUp: return "speaker.wave.3.fill" + case .appleTVRemoteVolumeDown: return "speaker.wave.1.fill" + case .appleTVRemoteMute: return "speaker.slash.fill" + case .leftThumbstick: return "l.circle" + case .rightThumbstick: return "r.circle" + case .a: return "a.circle" + case .b: return "b.circle" + case .x: return "x.circle" + case .y: return "y.circle" + case .touchpadButton: return "hand.point.up.left" + case .touchpadTap: return "hand.tap" + case .leftTouchpadButton, .rightTouchpadButton: return "hand.point.up.left" + case .leftTouchpadTap, .rightTouchpadTap: return "hand.tap" + case .micMute: return "mic.slash" + case .xboxPaddle1: return "l.button.roundedbottom.horizontal" + case .xboxPaddle2: return "r.button.roundedbottom.horizontal" + case .xboxPaddle3: return "l.button.roundedbottom.horizontal.fill" + case .xboxPaddle4: return "r.button.roundedbottom.horizontal.fill" + default: return nil } } diff --git a/XboxControllerMapper/XboxControllerMapper/Models/KeyMapping.swift b/XboxControllerMapper/XboxControllerMapper/Models/KeyMapping.swift index 3405998e..36d2b679 100644 --- a/XboxControllerMapper/XboxControllerMapper/Models/KeyMapping.swift +++ b/XboxControllerMapper/XboxControllerMapper/Models/KeyMapping.swift @@ -1,5 +1,6 @@ import Foundation import CoreGraphics +import Carbon.HIToolbox // MARK: - KeyBindingRepresentable Protocol @@ -116,10 +117,10 @@ extension KeyBindingRepresentable { var displayString: String { var parts: [String] = [] - if modifiers.command { parts.append("⌘") } - if modifiers.option { parts.append("⌥") } - if modifiers.shift { parts.append("⇧") } - if modifiers.control { parts.append("⌃") } + if modifiers.command { parts.append(ModifierFlags.label(for: modifiers.commandSide) + "⌘") } + if modifiers.option { parts.append(ModifierFlags.label(for: modifiers.optionSide) + "⌥") } + if modifiers.shift { parts.append(ModifierFlags.label(for: modifiers.shiftSide) + "⇧") } + if modifiers.control { parts.append(ModifierFlags.label(for: modifiers.controlSide) + "⌃") } if let keyCode = keyCode { parts.append(KeyCodeMapping.displayName(for: keyCode)) @@ -257,10 +258,10 @@ struct KeyMapping: Codable, Equatable, ExecutableAction { var parts: [String] = [] - if modifiers.command { parts.append("⌘") } - if modifiers.option { parts.append("⌥") } - if modifiers.shift { parts.append("⇧") } - if modifiers.control { parts.append("⌃") } + if modifiers.command { parts.append(ModifierFlags.label(for: modifiers.commandSide) + "⌘") } + if modifiers.option { parts.append(ModifierFlags.label(for: modifiers.optionSide) + "⌥") } + if modifiers.shift { parts.append(ModifierFlags.label(for: modifiers.shiftSide) + "⇧") } + if modifiers.control { parts.append(ModifierFlags.label(for: modifiers.controlSide) + "⌃") } if let keyCode = keyCode { parts.append(KeyCodeMapping.displayName(for: keyCode)) @@ -383,10 +384,10 @@ struct LongHoldMapping: Codable, Equatable, ExecutableAction { return "Script" } var parts: [String] = [] - if modifiers.command { parts.append("⌘") } - if modifiers.option { parts.append("⌥") } - if modifiers.shift { parts.append("⇧") } - if modifiers.control { parts.append("⌃") } + if modifiers.command { parts.append(ModifierFlags.label(for: modifiers.commandSide) + "⌘") } + if modifiers.option { parts.append(ModifierFlags.label(for: modifiers.optionSide) + "⌥") } + if modifiers.shift { parts.append(ModifierFlags.label(for: modifiers.shiftSide) + "⇧") } + if modifiers.control { parts.append(ModifierFlags.label(for: modifiers.controlSide) + "⌃") } if let keyCode = keyCode { parts.append(KeyCodeMapping.displayName(for: keyCode)) } else if parts.isEmpty { @@ -470,10 +471,10 @@ struct DoubleTapMapping: Codable, Equatable, ExecutableAction { return "Script" } var parts: [String] = [] - if modifiers.command { parts.append("⌘") } - if modifiers.option { parts.append("⌥") } - if modifiers.shift { parts.append("⇧") } - if modifiers.control { parts.append("⌃") } + if modifiers.command { parts.append(ModifierFlags.label(for: modifiers.commandSide) + "⌘") } + if modifiers.option { parts.append(ModifierFlags.label(for: modifiers.optionSide) + "⌥") } + if modifiers.shift { parts.append(ModifierFlags.label(for: modifiers.shiftSide) + "⇧") } + if modifiers.control { parts.append(ModifierFlags.label(for: modifiers.controlSide) + "⌃") } if let keyCode = keyCode { parts.append(KeyCodeMapping.displayName(for: keyCode)) } else if parts.isEmpty { @@ -538,6 +539,14 @@ struct RepeatMapping: Codable, Equatable { } } +/// Identifies which physical side of a modifier key to press. +/// When nil on `ModifierFlags`, the modifier is treated generically (the OS sees +/// `.maskCommand` regardless and the simulator pre-presses the Left keycode). +enum ModifierSide: String, Codable, Equatable { + case left + case right +} + /// Represents modifier key flags in a Codable-friendly way struct ModifierFlags: Codable, Equatable { var command: Bool = false @@ -545,8 +554,17 @@ struct ModifierFlags: Codable, Equatable { var shift: Bool = false var control: Bool = false + /// Optional left/right side for each modifier. nil means "either side" — + /// the simulator presses the Left keycode by default. Only meaningful when + /// the corresponding modifier flag is true. + var commandSide: ModifierSide? + var optionSide: ModifierSide? + var shiftSide: ModifierSide? + var controlSide: ModifierSide? + private enum CodingKeys: String, CodingKey { case command, option, shift, control + case commandSide, optionSide, shiftSide, controlSide } init(from decoder: Decoder) throws { @@ -555,20 +573,39 @@ struct ModifierFlags: Codable, Equatable { option = try container.decode(.option, default: false) shift = try container.decode(.shift, default: false) control = try container.decode(.control, default: false) + commandSide = try container.decodeIfPresent(ModifierSide.self, forKey: .commandSide) + optionSide = try container.decodeIfPresent(ModifierSide.self, forKey: .optionSide) + shiftSide = try container.decodeIfPresent(ModifierSide.self, forKey: .shiftSide) + controlSide = try container.decodeIfPresent(ModifierSide.self, forKey: .controlSide) } - init(command: Bool = false, option: Bool = false, shift: Bool = false, control: Bool = false) { + init( + command: Bool = false, + option: Bool = false, + shift: Bool = false, + control: Bool = false, + commandSide: ModifierSide? = nil, + optionSide: ModifierSide? = nil, + shiftSide: ModifierSide? = nil, + controlSide: ModifierSide? = nil + ) { self.command = command self.option = option self.shift = shift self.control = control + self.commandSide = commandSide + self.optionSide = optionSide + self.shiftSide = shiftSide + self.controlSide = controlSide } var hasAny: Bool { command || option || shift || control } - /// Convert to CGEventFlags + /// Convert to CGEventFlags. The mask is the same regardless of side — at the OS + /// flag level, Left and Right ⌘ both set `.maskCommand`. Side only affects which + /// virtualKey the simulator pre-presses (see `virtualKey(forMask:)`). var cgEventFlags: CGEventFlags { var flags: CGEventFlags = [] if command { flags.insert(.maskCommand) } @@ -578,8 +615,34 @@ struct ModifierFlags: Codable, Equatable { return flags } + /// Returns the side-specific virtual keycode for a modifier mask. Defaults to the + /// Left variant when no side was selected. Returns nil for non-modifier masks. + func virtualKey(forMask mask: CGEventFlags) -> CGKeyCode? { + switch mask { + case .maskCommand: + return CGKeyCode(commandSide == .right ? kVK_RightCommand : kVK_Command) + case .maskAlternate: + return CGKeyCode(optionSide == .right ? kVK_RightOption : kVK_Option) + case .maskShift: + return CGKeyCode(shiftSide == .right ? kVK_RightShift : kVK_Shift) + case .maskControl: + return CGKeyCode(controlSide == .right ? kVK_RightControl : kVK_Control) + default: + return nil + } + } + static let command = ModifierFlags(command: true) static let option = ModifierFlags(option: true) static let shift = ModifierFlags(shift: true) static let control = ModifierFlags(control: true) + + /// "L" / "R" prefix string for modifier display, or empty when no side is set. + static func label(for side: ModifierSide?) -> String { + switch side { + case .left: return "L" + case .right: return "R" + case .none: return "" + } + } } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Input/InputSimulator.swift b/XboxControllerMapper/XboxControllerMapper/Services/Input/InputSimulator.swift index f8f5bc9f..18d05892 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Input/InputSimulator.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Input/InputSimulator.swift @@ -4,12 +4,27 @@ import AppKit import IOKit.hidsystem import ApplicationServices.HIServices +struct ScrollEvent: Sendable, Equatable { + var dx: CGFloat + var dy: CGFloat + var phase: CGScrollPhase? = nil + var momentumPhase: CGMomentumScrollPhase? = nil + var isContinuous: Bool = false + var flags: CGEventFlags = [] +} + protocol InputSimulatorProtocol: Sendable { func pressKey(_ keyCode: CGKeyCode, modifiers: CGEventFlags) + /// Side-aware variant of `pressKey` — the simulator picks the Left/Right + /// modifier virtualKey based on `ModifierFlags.{command,option,shift,control}Side`. + /// Default implementation falls back to the flag-only path. + func pressKey(_ keyCode: CGKeyCode, modifiers: ModifierFlags) func keyDown(_ keyCode: CGKeyCode, modifiers: CGEventFlags) func keyUp(_ keyCode: CGKeyCode) func holdModifier(_ modifier: CGEventFlags) func releaseModifier(_ modifier: CGEventFlags) + func holdModifierKey(_ keyCode: CGKeyCode) + func releaseModifierKey(_ keyCode: CGKeyCode) func releaseAllModifiers() func isHoldingModifiers(_ modifier: CGEventFlags) -> Bool func getHeldModifiers() -> CGEventFlags @@ -17,14 +32,7 @@ protocol InputSimulatorProtocol: Sendable { func moveMouseNative(dx: Int, dy: Int) func warpMouseTo(point: CGPoint) var isLeftMouseButtonHeld: Bool { get } - func scroll( - dx: CGFloat, - dy: CGFloat, - phase: CGScrollPhase?, - momentumPhase: CGMomentumScrollPhase?, - isContinuous: Bool, - flags: CGEventFlags - ) + func scroll(event: ScrollEvent) func executeMapping(_ mapping: KeyMapping) func startHoldMapping(_ mapping: KeyMapping) func stopHoldMapping(_ mapping: KeyMapping) @@ -33,7 +41,37 @@ protocol InputSimulatorProtocol: Sendable { extension InputSimulatorProtocol { func scroll(dx: CGFloat, dy: CGFloat) { - scroll(dx: dx, dy: dy, phase: nil, momentumPhase: nil, isContinuous: false, flags: []) + scroll(event: ScrollEvent(dx: dx, dy: dy)) + } + + /// Default implementation — conformers that don't care about L/R side fall back + /// to the flag-only path. `InputSimulator` overrides this with a side-aware impl. + func pressKey(_ keyCode: CGKeyCode, modifiers: ModifierFlags) { + pressKey(keyCode, modifiers: modifiers.cgEventFlags) + } + + func holdModifierKey(_ keyCode: CGKeyCode) { + holdModifier(KeyCodeMapping.modifierFlag(for: keyCode)) + } + + func releaseModifierKey(_ keyCode: CGKeyCode) { + releaseModifier(KeyCodeMapping.modifierFlag(for: keyCode)) + } + + func holdModifiers(_ modifiers: ModifierFlags) { + for mask in ModifierKeyState.modifierPressOrder where modifiers.cgEventFlags.contains(mask) { + if let keyCode = modifiers.virtualKey(forMask: mask) { + holdModifierKey(keyCode) + } + } + } + + func releaseModifiers(_ modifiers: ModifierFlags) { + for mask in ModifierKeyState.modifierReleaseOrder where modifiers.cgEventFlags.contains(mask) { + if let keyCode = modifiers.virtualKey(forMask: mask) { + releaseModifierKey(keyCode) + } + } } } @@ -53,6 +91,14 @@ private class ModifierKeyState { static let modifierMasks: [CGEventFlags] = [ .maskCommand, .maskAlternate, .maskShift, .maskControl ] + + static let modifierPressOrder: [CGEventFlags] = [ + .maskCommand, .maskShift, .maskAlternate, .maskControl + ] + + static let modifierReleaseOrder: [CGEventFlags] = [ + .maskControl, .maskAlternate, .maskShift, .maskCommand + ] } @@ -179,6 +225,12 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { CGEventFlags.maskControl.rawValue: 0 ] + /// The exact physical modifier key currently posted for each mask. + /// Counts stay mask-based for overlap semantics, but key-up must mirror + /// the original left/right virtual key or side-aware remappers can see + /// an unmatched key-down. + private var modifierHeldKeyCodes: [UInt64: CGKeyCode] = [:] + /// Track if we've warned about accessibility private var hasWarnedAboutAccessibility = false @@ -202,9 +254,33 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { /// Simulates a key press with optional modifiers func pressKey(_ keyCode: CGKeyCode, modifiers: CGEventFlags = []) { + pressKeyInternal(keyCode, modifiers: modifiers, modifierSides: nil) + } + + /// Side-aware variant — picks Left/Right modifier virtualKeys per `ModifierFlags`. + func pressKey(_ keyCode: CGKeyCode, modifiers: ModifierFlags) { + pressKeyInternal(keyCode, modifiers: modifiers.cgEventFlags, modifierSides: modifiers) + } + + /// Picks the modifier pre-press keycode for a given mask. Honors `modifierSides` + /// when supplied; otherwise defaults to the Left variant (existing behavior). + private static func modifierVirtualKey(for mask: CGEventFlags, sides: ModifierFlags?) -> Int { + if let sides, let keyCode = sides.virtualKey(forMask: mask) { + return Int(keyCode) + } + switch mask { + case .maskCommand: return kVK_Command + case .maskAlternate: return kVK_Option + case .maskShift: return kVK_Shift + case .maskControl: return kVK_Control + default: return 0 + } + } + + private func pressKeyInternal(_ keyCode: CGKeyCode, modifiers: CGEventFlags, modifierSides: ModifierFlags?) { LatencyDiagnostics.mark("input.pressKey \(keyCode)") if shouldRelayUniversalControlAction(), - UniversalControlMouseRelay.shared.sendKeyPress(keyCode, modifiers: modifiers) { + sendUniversalControlKeyPress(keyCode, modifiers: modifiers, modifierSides: modifierSides) { return } @@ -249,18 +325,19 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { var currentFlags = startingFlags - // Helper to press modifier - func pressMod(_ key: Int, flag: CGEventFlags) { + // Helper to press modifier (side-aware via modifierSides) + func pressMod(flag: CGEventFlags) { + let key = InputSimulator.modifierVirtualKey(for: flag, sides: modifierSides) currentFlags.insert(flag) self.postKeyEvent(keyCode: CGKeyCode(key), keyDown: true, flags: currentFlags) usleep(Config.modifierPressDelay) } // Press modifier keys first (Command -> Shift -> Option -> Control) - if modifiersToPress.contains(.maskCommand) { pressMod(kVK_Command, flag: .maskCommand) } - if modifiersToPress.contains(.maskShift) { pressMod(kVK_Shift, flag: .maskShift) } - if modifiersToPress.contains(.maskAlternate){ pressMod(kVK_Option, flag: .maskAlternate) } - if modifiersToPress.contains(.maskControl) { pressMod(kVK_Control, flag: .maskControl) } + if modifiersToPress.contains(.maskCommand) { pressMod(flag: .maskCommand) } + if modifiersToPress.contains(.maskShift) { pressMod(flag: .maskShift) } + if modifiersToPress.contains(.maskAlternate){ pressMod(flag: .maskAlternate) } + if modifiersToPress.contains(.maskControl) { pressMod(flag: .maskControl) } // Small delay after modifiers if !modifiersToPress.isEmpty { @@ -277,18 +354,19 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { usleep(Config.preReleaseDelay) } - // Helper to release modifier - func releaseMod(_ key: Int, flag: CGEventFlags) { + // Helper to release modifier (side-aware — must match what we pressed) + func releaseMod(flag: CGEventFlags) { + let key = InputSimulator.modifierVirtualKey(for: flag, sides: modifierSides) currentFlags.remove(flag) self.postKeyEvent(keyCode: CGKeyCode(key), keyDown: false, flags: currentFlags) usleep(Config.modifierPressDelay) } // Release modifier keys (Control -> Option -> Shift -> Command) - if modifiersToPress.contains(.maskControl) { releaseMod(kVK_Control, flag: .maskControl) } - if modifiersToPress.contains(.maskAlternate){ releaseMod(kVK_Option, flag: .maskAlternate) } - if modifiersToPress.contains(.maskShift) { releaseMod(kVK_Shift, flag: .maskShift) } - if modifiersToPress.contains(.maskCommand) { releaseMod(kVK_Command, flag: .maskCommand) } + if modifiersToPress.contains(.maskControl) { releaseMod(flag: .maskControl) } + if modifiersToPress.contains(.maskAlternate){ releaseMod(flag: .maskAlternate) } + if modifiersToPress.contains(.maskShift) { releaseMod(flag: .maskShift) } + if modifiersToPress.contains(.maskCommand) { releaseMod(flag: .maskCommand) } #if DEBUG print(" ✅ Key sequence completed") @@ -296,6 +374,17 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { } } + private func sendUniversalControlKeyPress( + _ keyCode: CGKeyCode, + modifiers: CGEventFlags, + modifierSides: ModifierFlags? + ) -> Bool { + if let modifierSides { + return UniversalControlMouseRelay.shared.sendKeyPress(keyCode, modifiers: modifierSides) + } + return UniversalControlMouseRelay.shared.sendKeyPress(keyCode, modifiers: modifiers) + } + /// Posts a single key event private func postKeyEvent(keyCode: CGKeyCode, keyDown: Bool, flags: CGEventFlags = []) { // Use the configured source @@ -307,7 +396,17 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { // Add special flags for arrow keys, function keys, etc. // These flags (Fn, NumPad) are required for apps like Rectangle to recognize shortcuts let specialFlags = specialKeyFlags(for: keyCode) - let combinedFlags = flags.union(specialFlags) + var combinedFlags = flags.union(specialFlags) + + // When posting a modifier key itself (left/right Command, Option, Shift, Control), + // the key-down event must carry its own modifier flag for macOS to register the key. + // Posting kVK_Command without .maskCommand causes the OS to drop the modifier state. + if keyDown { + let ownFlag = KeyCodeMapping.modifierFlag(for: keyCode) + if !ownFlag.isEmpty { + combinedFlags.insert(ownFlag) + } + } // Always set flags explicitly to override any inherited state from the event source // (e.g., prevents Fn/Globe key from being inherited from HID system state) @@ -416,6 +515,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { // First time this modifier is being held heldModifiers.insert(mask) if let vKey = ModifierKeyState.maskToKeyCode[key] { + modifierHeldKeyCodes[key] = CGKeyCode(vKey) if let event = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(vKey), keyDown: true) { event.flags = heldModifiers event.post(tap: .cghidEventTap) @@ -451,8 +551,10 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { if heldModifiers.contains(mask) { NSLog("[InputSimulator] WARNING: modifier 0x%llx in heldModifiers but count is 0 — force-removing to prevent stuck modifier", key) heldModifiers.remove(mask) - if let vKey = ModifierKeyState.maskToKeyCode[key] { - if let event = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(vKey), keyDown: false) { + let releaseKeyCode = modifierHeldKeyCodes[key] ?? ModifierKeyState.maskToKeyCode[key].map(CGKeyCode.init) + modifierHeldKeyCodes[key] = nil + if let releaseKeyCode { + if let event = CGEvent(keyboardEventSource: source, virtualKey: releaseKeyCode, keyDown: false) { event.flags = heldModifiers event.post(tap: .cghidEventTap) } @@ -465,8 +567,10 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { if count == 1 { // Last button holding this modifier released heldModifiers.remove(mask) - if let vKey = ModifierKeyState.maskToKeyCode[key] { - if let event = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(vKey), keyDown: false) { + let releaseKeyCode = modifierHeldKeyCodes[key] ?? ModifierKeyState.maskToKeyCode[key].map(CGKeyCode.init) + modifierHeldKeyCodes[key] = nil + if let releaseKeyCode { + if let event = CGEvent(keyboardEventSource: source, virtualKey: releaseKeyCode, keyDown: false) { event.flags = heldModifiers event.post(tap: .cghidEventTap) } else { @@ -477,6 +581,97 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { } } + /// Holds a specific modifier key, preserving left/right side identity. + /// + /// Unlike `holdModifier(_:)` which takes a mask (so Left and Right Command collapse + /// to `.maskCommand`), this posts the exact virtualKey passed in — so a button mapped + /// to Right Command will actually emit kVK_RightCommand events to the OS. + /// + /// The internal reference count is keyed by mask, so holding Left Cmd then Right Cmd + /// only emits one key-down. The first physical keycode is remembered and released + /// when the final reference ends. + func holdModifierKey(_ keyCode: CGKeyCode) { + let mask = KeyCodeMapping.modifierFlag(for: keyCode) + guard !mask.isEmpty else { return } + + if shouldRelayUniversalControlAction(), + UniversalControlMouseRelay.shared.sendKeyDown(keyCode, modifiers: []) { + // Remote side runs the same key-down path and will add the modifier flag + // in postKeyEvent. Track locally so getHeldModifiers stays consistent. + recordHeldModifierKey(keyCode) + return + } + + guard checkAccessibility() else { return } + guard let source = eventSource else { return } + + stateLock.lock() + defer { stateLock.unlock() } + + let key = mask.rawValue + let count = modifierCounts[key] ?? 0 + modifierCounts[key] = count + 1 + + if count == 0 { + heldModifiers.insert(mask) + modifierHeldKeyCodes[key] = keyCode + if let event = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true) { + event.flags = heldModifiers + event.post(tap: .cghidEventTap) + } else { + NSLog("[InputSimulator] Failed to create modifier key-down event for keyCode %d - check Accessibility permissions", keyCode) + } + } + } + + /// Releases a previously held specific modifier key, preserving left/right side identity. + func releaseModifierKey(_ keyCode: CGKeyCode) { + let mask = KeyCodeMapping.modifierFlag(for: keyCode) + guard !mask.isEmpty else { return } + + if shouldRelayUniversalControlAction(), + UniversalControlMouseRelay.shared.sendKeyUp(keyCode) { + recordReleasedModifier(mask) + return + } + + guard checkAccessibility() else { return } + guard let source = eventSource else { return } + + stateLock.lock() + defer { stateLock.unlock() } + + let key = mask.rawValue + let count = modifierCounts[key] ?? 0 + guard count > 0 else { + // Underflow protection — mirrors releaseModifier behavior + if heldModifiers.contains(mask) { + NSLog("[InputSimulator] WARNING: modifier 0x%llx in heldModifiers but count is 0 — force-removing to prevent stuck modifier", key) + heldModifiers.remove(mask) + let releaseKeyCode = modifierHeldKeyCodes[key] ?? keyCode + modifierHeldKeyCodes[key] = nil + if let event = CGEvent(keyboardEventSource: source, virtualKey: releaseKeyCode, keyDown: false) { + event.flags = heldModifiers + event.post(tap: .cghidEventTap) + } + } + return + } + modifierCounts[key] = count - 1 + + if count == 1 { + heldModifiers.remove(mask) + let releaseKeyCode = modifierHeldKeyCodes[key] ?? keyCode + modifierHeldKeyCodes[key] = nil + if let event = CGEvent(keyboardEventSource: source, virtualKey: releaseKeyCode, keyDown: false) { + event.flags = heldModifiers + event.post(tap: .cghidEventTap) + } else { + NSLog("[InputSimulator] Failed to create modifier key-up event for keyCode %d - check Accessibility permissions", keyCode) + } + } + } + /// Checks if we are currently holding the specified modifiers via controller func isHoldingModifiers(_ modifier: CGEventFlags) -> Bool { stateLock.lock() @@ -510,8 +705,10 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { for mask in ModifierKeyState.modifierMasks where heldModifiers.contains(mask) { let key = mask.rawValue heldModifiers.remove(mask) - if let vKey = ModifierKeyState.maskToKeyCode[key] { - if let event = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(vKey), keyDown: false) { + let releaseKeyCode = modifierHeldKeyCodes[key] ?? ModifierKeyState.maskToKeyCode[key].map(CGKeyCode.init) + modifierHeldKeyCodes[key] = nil + if let releaseKeyCode { + if let event = CGEvent(keyboardEventSource: source, virtualKey: releaseKeyCode, keyDown: false) { event.flags = heldModifiers event.post(tap: .cghidEventTap) } else { @@ -524,6 +721,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { for key in modifierCounts.keys { modifierCounts[key] = 0 } + modifierHeldKeyCodes.removeAll() heldModifiers = [] } @@ -533,7 +731,27 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { for mask in ModifierKeyState.modifierMasks where modifier.contains(mask) { let key = mask.rawValue - modifierCounts[key] = (modifierCounts[key] ?? 0) + 1 + let count = modifierCounts[key] ?? 0 + modifierCounts[key] = count + 1 + if count == 0, let vKey = ModifierKeyState.maskToKeyCode[key] { + modifierHeldKeyCodes[key] = CGKeyCode(vKey) + } + heldModifiers.insert(mask) + } + } + + private func recordHeldModifierKey(_ keyCode: CGKeyCode) { + let modifier = KeyCodeMapping.modifierFlag(for: keyCode) + stateLock.lock() + defer { stateLock.unlock() } + + for mask in ModifierKeyState.modifierMasks where modifier.contains(mask) { + let key = mask.rawValue + let count = modifierCounts[key] ?? 0 + modifierCounts[key] = count + 1 + if count == 0 { + modifierHeldKeyCodes[key] = keyCode + } heldModifiers.insert(mask) } } @@ -547,6 +765,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { let count = modifierCounts[key] ?? 0 if count <= 1 { modifierCounts[key] = 0 + modifierHeldKeyCodes[key] = nil heldModifiers.remove(mask) } else { modifierCounts[key] = count - 1 @@ -559,6 +778,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { for key in modifierCounts.keys { modifierCounts[key] = 0 } + modifierHeldKeyCodes.removeAll() heldModifiers = [] stateLock.unlock() } @@ -1130,14 +1350,14 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { private var hasShownZoomKeyboardShortcutWarning: Bool = false /// Scrolls by a delta - func scroll( - dx: CGFloat, - dy: CGFloat, - phase: CGScrollPhase?, - momentumPhase: CGMomentumScrollPhase?, - isContinuous: Bool, - flags: CGEventFlags - ) { + func scroll(event scrollEvent: ScrollEvent) { + let dx = scrollEvent.dx + let dy = scrollEvent.dy + let phase = scrollEvent.phase + let momentumPhase = scrollEvent.momentumPhase + let isContinuous = scrollEvent.isContinuous + let flags = scrollEvent.flags + if shouldRelayUniversalControlAction(), UniversalControlMouseRelay.shared.sendScroll( dx: dx, @@ -1408,7 +1628,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { private func performScrollAction(_ keyCode: CGKeyCode) { let dy: CGFloat = keyCode == KeyCodeMapping.scrollUp ? 10 : -10 - scroll(dx: 0, dy: dy, phase: nil, momentumPhase: nil, isContinuous: false, flags: []) + scroll(event: ScrollEvent(dx: 0, dy: dy)) } // MARK: - Mouse Button Simulation @@ -1862,14 +2082,23 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { #endif if let keyCode = mapping.keyCode { - // For any mapping with a key code, do a full press (handles both regular keys and mouse buttons) - pressKey(keyCode, modifiers: mapping.modifiers.cgEventFlags) + if KeyCodeMapping.isModifierKey(keyCode) { + // Tap a modifier key with left/right side preserved. + // Hold + delayed release matches the modifier-only mapping path below. + holdModifierKey(keyCode) + DispatchQueue.main.asyncAfter(deadline: .now() + Config.modifierReleaseCheckDelay) { [weak self] in + self?.releaseModifierKey(keyCode) + } + } else { + // For any mapping with a key code, do a full press (handles both regular keys and mouse buttons). + // Use the side-aware variant so the L/R modifier side selection on the mapping is honored. + pressKey(keyCode, modifiers: mapping.modifiers) + } } else if mapping.modifiers.hasAny { // Modifier-only mapping - tap the modifiers - let flags = mapping.modifiers.cgEventFlags - holdModifier(flags) + holdModifiers(mapping.modifiers) DispatchQueue.main.asyncAfter(deadline: .now() + Config.modifierReleaseCheckDelay) { [weak self] in - self?.releaseModifier(flags) + self?.releaseModifiers(mapping.modifiers) } } } @@ -1878,11 +2107,15 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { func startHoldMapping(_ mapping: KeyMapping) { // Hold any modifiers if mapping.modifiers.hasAny { - holdModifier(mapping.modifiers.cgEventFlags) + holdModifiers(mapping.modifiers) } // Hold the key/mouse button if let keyCode = mapping.keyCode { - keyDown(keyCode, modifiers: mapping.modifiers.cgEventFlags) + if KeyCodeMapping.isModifierKey(keyCode) { + holdModifierKey(keyCode) + } else { + keyDown(keyCode, modifiers: mapping.modifiers.cgEventFlags) + } } } @@ -1890,11 +2123,15 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { func stopHoldMapping(_ mapping: KeyMapping) { // Release the key/mouse button first if let keyCode = mapping.keyCode { - keyUp(keyCode) + if KeyCodeMapping.isModifierKey(keyCode) { + releaseModifierKey(keyCode) + } else { + keyUp(keyCode) + } } // Then release modifiers if mapping.modifiers.hasAny { - releaseModifier(mapping.modifiers.cgEventFlags) + releaseModifiers(mapping.modifiers) } } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Input/UniversalControlMouseRelay.swift b/XboxControllerMapper/XboxControllerMapper/Services/Input/UniversalControlMouseRelay.swift index a61ea570..e7e251cd 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Input/UniversalControlMouseRelay.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Input/UniversalControlMouseRelay.swift @@ -290,6 +290,7 @@ final class UniversalControlMouseRelay: @unchecked Sendable { private var client: NWConnection? private var clientHost: String? private var clientReceiveBuffer = "" + private var peerSupportsKP2 = false private var authenticator: UniversalControlRelayAuthenticator? private var pendingRelayPings: [String: (Bool, String) -> Void] = [:] private var pendingOutgoingCodePairing: OutgoingCodePairing? @@ -723,6 +724,7 @@ final class UniversalControlMouseRelay: @unchecked Sendable { activeIncomingPairingPrompt = nil pairedRemoteEndpoint = nil pairedRemoteEndpointKey = nil + peerSupportsKP2 = false remoteHandoffSuppressedUntil = .distantPast lock.unlock() resetAuthenticator(secretData: relaySharedSecretData()) @@ -797,7 +799,29 @@ final class UniversalControlMouseRelay: @unchecked Sendable { } func sendKeyPress(_ keyCode: CGKeyCode, modifiers: CGEventFlags) -> Bool { - sendLine("kp \(keyCode) \(modifiers.rawValue)") + sendLine(UniversalControlRelayKeyPressEncoding.line(keyCode: keyCode, modifiers: modifiers)) + } + + func sendKeyPress(_ keyCode: CGKeyCode, modifiers: ModifierFlags) -> Bool { + lock.lock() + let supportsKP2 = peerSupportsKP2 + lock.unlock() + + return sendLine( + UniversalControlRelayKeyPressEncoding.line( + keyCode: keyCode, + modifiers: modifiers, + peerSupportsKP2: supportsKP2 + ) + ) + } + + private static func modifierSide(from wireValue: Substring) -> ModifierSide? { + switch Int(wireValue) { + case 1: return .left + case 2: return .right + default: return nil + } } func sendControllerButtonPressed(_ button: ControllerButton) -> Bool { @@ -1166,6 +1190,7 @@ final class UniversalControlMouseRelay: @unchecked Sendable { lock.lock() client = connection clientHost = clientKey + peerSupportsKP2 = false didLogSendFailure = false didLogFirstSend = false lock.unlock() @@ -1749,7 +1774,7 @@ final class UniversalControlMouseRelay: @unchecked Sendable { return } if let data, !data.isEmpty, let text = String(data: data, encoding: .utf8) { - appendClientIncoming(text) + appendClientIncoming(text, from: connection) } if !isComplete { receiveNextFromClient(on: connection) @@ -1757,7 +1782,7 @@ final class UniversalControlMouseRelay: @unchecked Sendable { } } - private func appendClientIncoming(_ text: String) { + private func appendClientIncoming(_ text: String, from connection: NWConnection) { var lines: [String] = [] lock.lock() clientReceiveBuffer += text @@ -1774,7 +1799,7 @@ final class UniversalControlMouseRelay: @unchecked Sendable { lock.unlock() for line in lines { - handleClient(line: line) + handleClient(line: line, from: connection) } } @@ -1810,6 +1835,7 @@ final class UniversalControlMouseRelay: @unchecked Sendable { connection.stateUpdateHandler = { [weak self, weak connection] state in guard let connection else { return } if case .ready = state { + self?.sendConnectionCapabilities(on: connection) self?.receiveNext(on: connection) } else if case .failed(let error) = state { NSLog("[UCMouseRelay] Receive connection failed: %@", String(describing: error)) @@ -1918,6 +1944,10 @@ final class UniversalControlMouseRelay: @unchecked Sendable { return } + if command == "caps" { + return + } + lock.lock() let input = receiverInput let handlesIncomingRemoteInput = UniversalControlRelayRolePolicy.handlesIncomingRemoteInput( @@ -1993,6 +2023,28 @@ final class UniversalControlMouseRelay: @unchecked Sendable { input?.pressKey(CGKeyCode(keyCode), modifiers: CGEventFlags(rawValue: flagsRaw)) } logFirstReceive("key press \(keyCode)") + case "kp2": + guard parts.count == 7, + let keyCode = UInt16(parts[1]), + let flagsRaw = UInt64(parts[2]) else { return } + let flags = CGEventFlags(rawValue: flagsRaw) + let modifiers = ModifierFlags( + command: flags.contains(.maskCommand), + option: flags.contains(.maskAlternate), + shift: flags.contains(.maskShift), + control: flags.contains(.maskControl), + commandSide: Self.modifierSide(from: parts[3]), + optionSide: Self.modifierSide(from: parts[4]), + shiftSide: Self.modifierSide(from: parts[5]), + controlSide: Self.modifierSide(from: parts[6]) + ) + if handlesIncomingRemoteInput && KeyCodeMapping.isMouseButton(CGKeyCode(keyCode)) { + postRemoteMouseButton(keyCode: CGKeyCode(keyCode), down: true) + postRemoteMouseButton(keyCode: CGKeyCode(keyCode), down: false) + } else { + input?.pressKey(CGKeyCode(keyCode), modifiers: modifiers) + } + logFirstReceive("side-aware key press \(keyCode)") case "kd": guard parts.count == 3, let keyCode = UInt16(parts[1]), @@ -2000,8 +2052,13 @@ final class UniversalControlMouseRelay: @unchecked Sendable { if handlesIncomingRemoteInput && KeyCodeMapping.isMouseButton(CGKeyCode(keyCode)) { postRemoteMouseButton(keyCode: CGKeyCode(keyCode), down: true) } else { - updateRemoteMouseButtonState(keyCode: CGKeyCode(keyCode), isDown: true) - input?.keyDown(CGKeyCode(keyCode), modifiers: CGEventFlags(rawValue: flagsRaw)) + let cgKeyCode = CGKeyCode(keyCode) + updateRemoteMouseButtonState(keyCode: cgKeyCode, isDown: true) + if KeyCodeMapping.isModifierKey(cgKeyCode) { + input?.holdModifierKey(cgKeyCode) + } else { + input?.keyDown(cgKeyCode, modifiers: CGEventFlags(rawValue: flagsRaw)) + } } logFirstReceive("key down \(keyCode)") case "ku": @@ -2010,8 +2067,13 @@ final class UniversalControlMouseRelay: @unchecked Sendable { if handlesIncomingRemoteInput && KeyCodeMapping.isMouseButton(CGKeyCode(keyCode)) { postRemoteMouseButton(keyCode: CGKeyCode(keyCode), down: false) } else { - updateRemoteMouseButtonState(keyCode: CGKeyCode(keyCode), isDown: false) - input?.keyUp(CGKeyCode(keyCode)) + let cgKeyCode = CGKeyCode(keyCode) + updateRemoteMouseButtonState(keyCode: cgKeyCode, isDown: false) + if KeyCodeMapping.isModifierKey(cgKeyCode) { + input?.releaseModifierKey(cgKeyCode) + } else { + input?.keyUp(cgKeyCode) + } } logFirstReceive("key up \(keyCode)") case "hm": @@ -2035,14 +2097,14 @@ final class UniversalControlMouseRelay: @unchecked Sendable { let momentumRaw = Int32(parts[4]), let continuousRaw = Int(parts[5]), let flagsRaw = UInt64(parts[6]) else { return } - input?.scroll( + input?.scroll(event: ScrollEvent( dx: CGFloat(dx), dy: CGFloat(dy), phase: phaseRaw >= 0 ? CGScrollPhase(rawValue: UInt32(phaseRaw)) : nil, momentumPhase: momentumRaw >= 0 ? CGMomentumScrollPhase(rawValue: UInt32(momentumRaw)) : nil, isContinuous: continuousRaw != 0, flags: CGEventFlags(rawValue: flagsRaw) - ) + )) logFirstReceive("scroll dx=\(dx) dy=\(dy)") case "tt": guard parts.count == 4, @@ -2363,7 +2425,7 @@ final class UniversalControlMouseRelay: @unchecked Sendable { } } - private func handleClient(line: String) { + private func handleClient(line: String, from connection: NWConnection) { guard let payload = openIncomingLine(line) else { NSLog("[UCMouseRelay] Rejected unauthenticated client response") return @@ -2371,6 +2433,11 @@ final class UniversalControlMouseRelay: @unchecked Sendable { let parts = payload.split(separator: " ") guard let command = parts.first else { return } + if command == "caps" { + recordPeerCapabilities(from: parts, on: connection) + return + } + if command == "pong" { guard parts.count == 2 else { return } completeRelayPing( @@ -2442,6 +2509,21 @@ final class UniversalControlMouseRelay: @unchecked Sendable { } } + private func sendConnectionCapabilities(on connection: NWConnection) { + _ = sendAuthenticatedLine(UniversalControlRelayKeyPressEncoding.capabilityLine, on: connection) + } + + private func recordPeerCapabilities(from parts: [Substring], on connection: NWConnection) { + guard UniversalControlRelayKeyPressEncoding.supportsSideAwareKeyPress(parts) else { + return + } + lock.lock() + if client === connection { + peerSupportsKP2 = true + } + lock.unlock() + } + private func sendRemoteCursorStatus(on connection: NWConnection) { let point = UniversalControlRemoteMouseMovementPolicy.statusPoint( current: currentRemoteCGPoint(), @@ -2976,6 +3058,51 @@ final class UniversalControlMouseRelay: @unchecked Sendable { } } +struct UniversalControlRelayKeyPressEncoding { + static let sideAwareCapability = "kp2" + static let capabilityLine = "caps \(sideAwareCapability)" + + static func line(keyCode: CGKeyCode, modifiers: CGEventFlags) -> String { + "kp \(keyCode) \(modifiers.rawValue)" + } + + static func line( + keyCode: CGKeyCode, + modifiers: ModifierFlags, + peerSupportsKP2: Bool + ) -> String { + guard peerSupportsKP2, hasExplicitSide(modifiers) else { + return line(keyCode: keyCode, modifiers: modifiers.cgEventFlags) + } + + return "kp2 \(keyCode) \(modifiers.cgEventFlags.rawValue) " + + "\(wireValue(for: modifiers.commandSide)) " + + "\(wireValue(for: modifiers.optionSide)) " + + "\(wireValue(for: modifiers.shiftSide)) " + + "\(wireValue(for: modifiers.controlSide))" + } + + static func supportsSideAwareKeyPress(_ parts: [Substring]) -> Bool { + guard parts.first == "caps" else { return false } + return parts.dropFirst().contains { $0 == sideAwareCapability } + } + + private static func hasExplicitSide(_ modifiers: ModifierFlags) -> Bool { + modifiers.commandSide != nil || + modifiers.optionSide != nil || + modifiers.shiftSide != nil || + modifiers.controlSide != nil + } + + private static func wireValue(for side: ModifierSide?) -> Int { + switch side { + case .left: return 1 + case .right: return 2 + case .none: return 0 + } + } +} + struct UniversalControlHandoffEdgeDefaults { static let fallbackLocalEdges: [UniversalControlMouseRelay.HandoffEdge] = [.left, .right] diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Macros/MacroExecutor.swift b/XboxControllerMapper/XboxControllerMapper/Services/Macros/MacroExecutor.swift index a8ba05e0..330aa91f 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Macros/MacroExecutor.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Macros/MacroExecutor.swift @@ -76,12 +76,11 @@ class MacroExecutor: @unchecked Sendable { private func pressKeyMapping(_ mapping: KeyMapping) { if let keyCode = mapping.keyCode { - inputSimulator.pressKey(keyCode, modifiers: mapping.modifiers.cgEventFlags) + inputSimulator.pressKey(keyCode, modifiers: mapping.modifiers) } else if mapping.modifiers.hasAny { - let flags = mapping.modifiers.cgEventFlags - inputSimulator.holdModifier(flags) + inputSimulator.holdModifiers(mapping.modifiers) usleep(Config.keyPressDuration) - inputSimulator.releaseModifier(flags) + inputSimulator.releaseModifiers(mapping.modifiers) } } @@ -90,14 +89,27 @@ class MacroExecutor: @unchecked Sendable { // community profile can't crash the unsigned conversion to useconds_t. let clampedUs = max(0, min(duration * 1_000_000, Double(UInt32.max))) if let keyCode = mapping.keyCode { - inputSimulator.keyDown(keyCode, modifiers: mapping.modifiers.cgEventFlags) + if mapping.modifiers.hasAny { + inputSimulator.holdModifiers(mapping.modifiers) + } + if KeyCodeMapping.isModifierKey(keyCode) { + inputSimulator.holdModifierKey(keyCode) + } else { + inputSimulator.keyDown(keyCode, modifiers: mapping.modifiers.cgEventFlags) + } usleep(useconds_t(clampedUs)) - inputSimulator.keyUp(keyCode) + if KeyCodeMapping.isModifierKey(keyCode) { + inputSimulator.releaseModifierKey(keyCode) + } else { + inputSimulator.keyUp(keyCode) + } + if mapping.modifiers.hasAny { + inputSimulator.releaseModifiers(mapping.modifiers) + } } else if mapping.modifiers.hasAny { - let flags = mapping.modifiers.cgEventFlags - inputSimulator.holdModifier(flags) + inputSimulator.holdModifiers(mapping.modifiers) usleep(useconds_t(clampedUs)) - inputSimulator.releaseModifier(flags) + inputSimulator.releaseModifiers(mapping.modifiers) } } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ActionCommand.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ActionCommand.swift index 3a9ac46f..7435cc18 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ActionCommand.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ActionCommand.swift @@ -83,7 +83,7 @@ struct ScriptActionCommand: ActionCommand { /// Executes a key press with optional modifiers struct KeyPressActionCommand: ActionCommand { let keyCode: CGKeyCode - let modifiers: CGEventFlags + let modifiers: ModifierFlags let inputSimulator: InputSimulatorProtocol let action: any ExecutableAction @@ -92,7 +92,7 @@ struct KeyPressActionCommand: ActionCommand { if !UniversalControlMouseRelay.shared.isRoutingToRemote { // Notify on-screen keyboard of controller key press OnScreenKeyboardManager.shared.notifyControllerKeyPress( - keyCode: keyCode, modifiers: modifiers + keyCode: keyCode, modifiers: modifiers.cgEventFlags ) } return action.feedbackString @@ -101,15 +101,15 @@ struct KeyPressActionCommand: ActionCommand { /// Taps a modifier key (hold + delayed release) struct ModifierTapActionCommand: ActionCommand { - let modifierFlags: CGEventFlags + let modifiers: ModifierFlags let inputSimulator: InputSimulatorProtocol let inputQueue: DispatchQueue let action: any ExecutableAction func execute() -> String { - inputSimulator.holdModifier(modifierFlags) + inputSimulator.holdModifiers(modifiers) inputQueue.asyncAfter(deadline: .now() + Config.modifierReleaseCheckDelay) { [inputSimulator] in - inputSimulator.releaseModifier(modifierFlags) + inputSimulator.releaseModifiers(modifiers) } return action.feedbackString } @@ -178,7 +178,7 @@ struct ActionCommandFactory { if let keyCode = action.keyCode { return KeyPressActionCommand( keyCode: keyCode, - modifiers: action.modifiers.cgEventFlags, + modifiers: action.modifiers, inputSimulator: inputSimulator, action: action ) @@ -186,7 +186,7 @@ struct ActionCommandFactory { if action.modifiers.hasAny { return ModifierTapActionCommand( - modifierFlags: action.modifiers.cgEventFlags, + modifiers: action.modifiers, inputSimulator: inputSimulator, inputQueue: inputQueue, action: action diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonInteractionFlowPolicy.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonInteractionFlowPolicy.swift index 147c9040..39f802e3 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonInteractionFlowPolicy.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonInteractionFlowPolicy.swift @@ -51,17 +51,24 @@ enum ButtonInteractionFlowPolicy { return true } - static func shouldUseRealtimeHoldPath(mapping: KeyMapping, isChordPart: Bool) -> Bool { - guard !isChordPart, - mapping.effectiveActionType == .keyPress, - mapping.keyCode != nil, - mapping.longHoldMapping?.isEmpty ?? true, - mapping.doubleTapMapping?.isEmpty ?? true, - !(mapping.repeatMapping?.enabled ?? false) else { - return false - } - return true - } + static func shouldUseRealtimeHoldPath( + mapping: KeyMapping, + isChordPart: Bool, + isOtherLayerActivatorPress: Bool = false + ) -> Bool { + // When a different layer is active, its remap of another layer's + // activator should keep tap semantics instead of becoming a held key. + guard !isChordPart, + !isOtherLayerActivatorPress, + mapping.effectiveActionType == .keyPress, + mapping.keyCode != nil, + mapping.longHoldMapping?.isEmpty ?? true, + mapping.doubleTapMapping?.isEmpty ?? true, + !(mapping.repeatMapping?.enabled ?? false) else { + return false + } + return true + } static func releaseDecision( mapping: KeyMapping, diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonPressOrchestrationPolicy.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonPressOrchestrationPolicy.swift index d5cd65d5..27a709d2 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonPressOrchestrationPolicy.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonPressOrchestrationPolicy.swift @@ -35,6 +35,7 @@ enum ButtonPressOrchestrationPolicy { remoteSwipePredictionsVisible: Bool = false, isChordPart: Bool, isDPadPresetDirection: Bool = false, + isOtherLayerActivatorPress: Bool = false, lastTap: CFAbsoluteTime?, inputLatencyMode: InputLatencyMode = .standard ) -> Outcome { @@ -129,7 +130,8 @@ enum ButtonPressOrchestrationPolicy { inputLatencyMode == .realtime && ButtonInteractionFlowPolicy.shouldUseRealtimeHoldPath( mapping: mapping, - isChordPart: isChordPart + isChordPart: isChordPart, + isOtherLayerActivatorPress: isOtherLayerActivatorPress ) ) ) diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickHandler.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickHandler.swift index c0267e9a..dd952e59 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickHandler.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickHandler.swift @@ -515,12 +515,14 @@ extension MappingEngine { } inputSimulator.scroll( - dx: dx, - dy: dy, - phase: nil, - momentumPhase: nil, - isContinuous: false, - flags: inputSimulator.getHeldModifiers() + event: ScrollEvent( + dx: dx, + dy: dy, + phase: nil, + momentumPhase: nil, + isContinuous: false, + flags: inputSimulator.getHeldModifiers() + ) ) usageStatsService?.recordScrollDistance(dx: Double(dx), dy: Double(dy)) } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngine.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngine.swift index b826af6a..74e5ff18 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngine.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngine.swift @@ -615,6 +615,13 @@ class MappingEngine: ObservableObject { let directoryNavigatorVisible = localDirectoryNavigatorVisible || remoteOverlayState.directoryNavigatorVisible let mapping = effectiveMapping(for: button, in: profile) let isDPadPresetDirection = profile.dpadPreset.primaryKeyCode(for: button) == mapping?.keyCode + let isOtherLayerActivatorPress = state.lock.withLock { () -> Bool in + guard let activatorLayerId = state.layerActivatorMap[button], + let activeLayerId = state.activeLayerIds.last else { + return false + } + return activeLayerId != activatorLayerId + } let navigationModeActive = keyboardVisible ? (localKeyboardVisible ? OnScreenKeyboardManager.shared.threadSafeNavigationModeActive @@ -631,6 +638,7 @@ class MappingEngine: ObservableObject { remoteSwipePredictionsVisible: remoteOverlayState.swipePredictionsVisible, isChordPart: isChordPart, isDPadPresetDirection: isDPadPresetDirection, + isOtherLayerActivatorPress: isOtherLayerActivatorPress, lastTap: lastTap, inputLatencyMode: profile.inputLatencyMode ) diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/TouchpadInputHandler.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/TouchpadInputHandler.swift index 478ae6ac..d73cf42a 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/TouchpadInputHandler.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/TouchpadInputHandler.swift @@ -126,12 +126,14 @@ extension MappingEngine { } inputSimulator.scroll( - dx: CGFloat(dx), - dy: CGFloat(dy), - phase: nil, - momentumPhase: nil, - isContinuous: false, - flags: inputSimulator.getHeldModifiers() + event: ScrollEvent( + dx: CGFloat(dx), + dy: CGFloat(dy), + phase: nil, + momentumPhase: nil, + isContinuous: false, + flags: inputSimulator.getHeldModifiers() + ) ) usageStatsService?.recordScrollDistance(dx: dx, dy: dy) } @@ -153,15 +155,17 @@ extension MappingEngine { } guard abs(dy) > 0.1 else { return } - inputSimulator.scroll( - dx: 0, - dy: CGFloat(dy), - phase: nil, - momentumPhase: nil, - isContinuous: false, - flags: inputSimulator.getHeldModifiers() - ) - usageStatsService?.recordScrollDistance(dx: 0, dy: dy) + inputSimulator.scroll( + event: ScrollEvent( + dx: 0, + dy: CGFloat(dy), + phase: nil, + momentumPhase: nil, + isContinuous: false, + flags: inputSimulator.getHeldModifiers() + ) + ) + usageStatsService?.recordScrollDistance(dx: 0, dy: dy) } // MARK: - Touchpad Tap Gestures @@ -312,12 +316,14 @@ extension MappingEngine { if wasActive { inputSimulator.scroll( - dx: 0, - dy: 0, - phase: .ended, - momentumPhase: nil, - isContinuous: true, - flags: inputSimulator.getHeldModifiers() + event: ScrollEvent( + dx: 0, + dy: 0, + phase: .ended, + momentumPhase: nil, + isContinuous: true, + flags: inputSimulator.getHeldModifiers() + ) ) } state.lock.withLock { @@ -378,12 +384,14 @@ extension MappingEngine { if phase == .began { inputSimulator.scroll( - dx: 0, - dy: 0, - phase: .began, - momentumPhase: nil, - isContinuous: true, - flags: inputSimulator.getHeldModifiers() + event: ScrollEvent( + dx: 0, + dy: 0, + phase: .began, + momentumPhase: nil, + isContinuous: true, + flags: inputSimulator.getHeldModifiers() + ) ) } @@ -625,12 +633,14 @@ extension MappingEngine { residualY = combinedDy - sendDy if sendDx != 0 || sendDy != 0 { inputSimulator.scroll( - dx: CGFloat(sendDx), - dy: CGFloat(sendDy), - phase: .changed, - momentumPhase: nil, - isContinuous: true, - flags: inputSimulator.getHeldModifiers() + event: ScrollEvent( + dx: CGFloat(sendDx), + dy: CGFloat(sendDy), + phase: .changed, + momentumPhase: nil, + isContinuous: true, + flags: inputSimulator.getHeldModifiers() + ) ) usageStatsService?.recordScrollDistance(dx: sendDx, dy: sendDy) } @@ -651,12 +661,14 @@ extension MappingEngine { if idleInterval > Config.touchpadMomentumMaxIdleInterval { if wasActive { inputSimulator.scroll( - dx: 0, - dy: 0, - phase: nil, - momentumPhase: .end, - isContinuous: true, - flags: inputSimulator.getHeldModifiers() + event: ScrollEvent( + dx: 0, + dy: 0, + phase: nil, + momentumPhase: .end, + isContinuous: true, + flags: inputSimulator.getHeldModifiers() + ) ) } state.lock.withLock { @@ -676,12 +688,14 @@ extension MappingEngine { if speed < Config.touchpadMomentumStopVelocity { if wasActive { inputSimulator.scroll( - dx: 0, - dy: 0, - phase: nil, - momentumPhase: .end, - isContinuous: true, - flags: inputSimulator.getHeldModifiers() + event: ScrollEvent( + dx: 0, + dy: 0, + phase: nil, + momentumPhase: .end, + isContinuous: true, + flags: inputSimulator.getHeldModifiers() + ) ) } state.lock.withLock { @@ -706,12 +720,14 @@ extension MappingEngine { if sendDx != 0 || sendDy != 0 { let momentumPhase: CGMomentumScrollPhase = wasActive ? .continuous : .begin inputSimulator.scroll( - dx: CGFloat(sendDx), - dy: CGFloat(sendDy), - phase: nil, - momentumPhase: momentumPhase, - isContinuous: true, - flags: inputSimulator.getHeldModifiers() + event: ScrollEvent( + dx: CGFloat(sendDx), + dy: CGFloat(sendDy), + phase: nil, + momentumPhase: momentumPhase, + isContinuous: true, + flags: inputSimulator.getHeldModifiers() + ) ) wasActive = true } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager+FaviconCache.swift b/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager+FaviconCache.swift index 75d00ed6..2a720021 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager+FaviconCache.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager+FaviconCache.swift @@ -7,25 +7,47 @@ extension ProfileManager { /// Load cached favicons for all website links in all profiles. func loadCachedFavicons() { - for i in 0.. Data { + guard url.isFileURL else { + throw StreamDeckParseError.extractionFailed("URL must be a file URL") + } + + let sourcePath = url.path + + // Ensure it's an absolute path to prevent argument injection + guard sourcePath.hasPrefix("/") else { + throw StreamDeckParseError.extractionFailed("Invalid file path: must be an absolute path") + } + let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("streamdeck-import-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: tempDir) } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/UI/FaviconCache.swift b/XboxControllerMapper/XboxControllerMapper/Services/UI/FaviconCache.swift index 2123da61..82f93cd7 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/UI/FaviconCache.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/UI/FaviconCache.swift @@ -8,6 +8,7 @@ class FaviconCache { private let cacheDirectory: URL private let fileManager = FileManager.default + private let memoryCache = NSCache() private init() { let homeDir = fileManager.homeDirectoryForCurrentUser @@ -29,10 +30,26 @@ class FaviconCache { return cacheDirectory.appendingPathComponent(filename) } + /// Retrieve a favicon instantly if it's already in the memory cache + func memoryCachedFavicon(for websiteURL: String) -> Data? { + return memoryCache.object(forKey: websiteURL as NSString) as? Data + } + /// Load favicon from disk cache - func loadCachedFavicon(for websiteURL: String) -> Data? { + func loadCachedFavicon(for websiteURL: String) async -> Data? { + if let cached = memoryCache.object(forKey: websiteURL as NSString) { + return cached as Data + } + let url = cacheURL(for: websiteURL) - return try? Data(contentsOf: url) + let data = await Task.detached(priority: .background) { + try? Data(contentsOf: url) + }.value + + if let data = data { + memoryCache.setObject(data as NSData, forKey: websiteURL as NSString) + } + return data } /// Save favicon to disk cache. @@ -43,12 +60,14 @@ class FaviconCache { /// rename; the rename is itself atomic so the worst case is one of the /// two writers wins cleanly. func saveFavicon(_ data: Data, for websiteURL: String) { + memoryCache.setObject(data as NSData, forKey: websiteURL as NSString) let url = cacheURL(for: websiteURL) try? AtomicFileWriter.write(data, to: url) } /// Delete cached favicon func deleteCachedFavicon(for websiteURL: String) { + memoryCache.removeObject(forKey: websiteURL as NSString) let url = cacheURL(for: websiteURL) try? fileManager.removeItem(at: url) } @@ -59,7 +78,7 @@ class FaviconCache { /// Returns the favicon data, or nil if fetch failed func fetchFavicon(for websiteURL: String, forceRefresh: Bool = false) async -> Data? { // Check cache first unless forcing refresh - if !forceRefresh, let cached = loadCachedFavicon(for: websiteURL) { + if !forceRefresh, let cached = await loadCachedFavicon(for: websiteURL) { return cached } diff --git a/XboxControllerMapper/XboxControllerMapper/Utilities/KeyCodeMapping.swift b/XboxControllerMapper/XboxControllerMapper/Utilities/KeyCodeMapping.swift index e0362770..5181228d 100644 --- a/XboxControllerMapper/XboxControllerMapper/Utilities/KeyCodeMapping.swift +++ b/XboxControllerMapper/XboxControllerMapper/Utilities/KeyCodeMapping.swift @@ -35,13 +35,20 @@ enum KeyCodeMapping { static let f11: CGKeyCode = CGKeyCode(kVK_F11) static let f12: CGKeyCode = CGKeyCode(kVK_F12) - // MARK: - Modifier Keys + // MARK: - Modifier Keys (Left) static let command: CGKeyCode = CGKeyCode(kVK_Command) static let shift: CGKeyCode = CGKeyCode(kVK_Shift) static let option: CGKeyCode = CGKeyCode(kVK_Option) static let control: CGKeyCode = CGKeyCode(kVK_Control) + // MARK: - Modifier Keys (Right) + + static let rightCommand: CGKeyCode = CGKeyCode(kVK_RightCommand) + static let rightShift: CGKeyCode = CGKeyCode(kVK_RightShift) + static let rightOption: CGKeyCode = CGKeyCode(kVK_RightOption) + static let rightControl: CGKeyCode = CGKeyCode(kVK_RightControl) + // MARK: - Number Keys static let key0: CGKeyCode = CGKeyCode(kVK_ANSI_0) @@ -151,144 +158,151 @@ enum KeyCodeMapping { // MARK: - Display Names /// Returns a human-readable name for a key code - static func displayName(for keyCode: CGKeyCode) -> String { - switch Int(keyCode) { + private static let displayNames: [Int: String] = [ // Special keys - case kVK_Return: return "Return" - case kVK_Tab: return "Tab" - case kVK_Space: return "Space" - case kVK_Delete: return "Delete" - case kVK_Escape: return "Esc" - case kVK_ForwardDelete: return "⌦" + kVK_Return: "Return", + kVK_Tab: "Tab", + kVK_Space: "Space", + kVK_Delete: "Delete", + kVK_Escape: "Esc", + kVK_ForwardDelete: "⌦", // Arrow keys - case kVK_LeftArrow: return "←" - case kVK_RightArrow: return "→" - case kVK_UpArrow: return "↑" - case kVK_DownArrow: return "↓" + kVK_LeftArrow: "←", + kVK_RightArrow: "→", + kVK_UpArrow: "↑", + kVK_DownArrow: "↓", // Function keys F1-F12 - case kVK_F1: return "F1" - case kVK_F2: return "F2" - case kVK_F3: return "F3" - case kVK_F4: return "F4" - case kVK_F5: return "F5" - case kVK_F6: return "F6" - case kVK_F7: return "F7" - case kVK_F8: return "F8" - case kVK_F9: return "F9" - case kVK_F10: return "F10" - case kVK_F11: return "F11" - case kVK_F12: return "F12" + kVK_F1: "F1", + kVK_F2: "F2", + kVK_F3: "F3", + kVK_F4: "F4", + kVK_F5: "F5", + kVK_F6: "F6", + kVK_F7: "F7", + kVK_F8: "F8", + kVK_F9: "F9", + kVK_F10: "F10", + kVK_F11: "F11", + kVK_F12: "F12", // Extended function keys F13-F20 - case kVK_F13: return "F13" - case kVK_F14: return "F14" - case kVK_F15: return "F15" - case kVK_F16: return "F16" - case kVK_F17: return "F17" - case kVK_F18: return "F18" - case kVK_F19: return "F19" - case kVK_F20: return "F20" + kVK_F13: "F13", + kVK_F14: "F14", + kVK_F15: "F15", + kVK_F16: "F16", + kVK_F17: "F17", + kVK_F18: "F18", + kVK_F19: "F19", + kVK_F20: "F20", // Numbers - case kVK_ANSI_0: return "0" - case kVK_ANSI_1: return "1" - case kVK_ANSI_2: return "2" - case kVK_ANSI_3: return "3" - case kVK_ANSI_4: return "4" - case kVK_ANSI_5: return "5" - case kVK_ANSI_6: return "6" - case kVK_ANSI_7: return "7" - case kVK_ANSI_8: return "8" - case kVK_ANSI_9: return "9" + kVK_ANSI_0: "0", + kVK_ANSI_1: "1", + kVK_ANSI_2: "2", + kVK_ANSI_3: "3", + kVK_ANSI_4: "4", + kVK_ANSI_5: "5", + kVK_ANSI_6: "6", + kVK_ANSI_7: "7", + kVK_ANSI_8: "8", + kVK_ANSI_9: "9", // Letters - case kVK_ANSI_A: return "A" - case kVK_ANSI_B: return "B" - case kVK_ANSI_C: return "C" - case kVK_ANSI_D: return "D" - case kVK_ANSI_E: return "E" - case kVK_ANSI_F: return "F" - case kVK_ANSI_G: return "G" - case kVK_ANSI_H: return "H" - case kVK_ANSI_I: return "I" - case kVK_ANSI_J: return "J" - case kVK_ANSI_K: return "K" - case kVK_ANSI_L: return "L" - case kVK_ANSI_M: return "M" - case kVK_ANSI_N: return "N" - case kVK_ANSI_O: return "O" - case kVK_ANSI_P: return "P" - case kVK_ANSI_Q: return "Q" - case kVK_ANSI_R: return "R" - case kVK_ANSI_S: return "S" - case kVK_ANSI_T: return "T" - case kVK_ANSI_U: return "U" - case kVK_ANSI_V: return "V" - case kVK_ANSI_W: return "W" - case kVK_ANSI_X: return "X" - case kVK_ANSI_Y: return "Y" - case kVK_ANSI_Z: return "Z" + kVK_ANSI_A: "A", + kVK_ANSI_B: "B", + kVK_ANSI_C: "C", + kVK_ANSI_D: "D", + kVK_ANSI_E: "E", + kVK_ANSI_F: "F", + kVK_ANSI_G: "G", + kVK_ANSI_H: "H", + kVK_ANSI_I: "I", + kVK_ANSI_J: "J", + kVK_ANSI_K: "K", + kVK_ANSI_L: "L", + kVK_ANSI_M: "M", + kVK_ANSI_N: "N", + kVK_ANSI_O: "O", + kVK_ANSI_P: "P", + kVK_ANSI_Q: "Q", + kVK_ANSI_R: "R", + kVK_ANSI_S: "S", + kVK_ANSI_T: "T", + kVK_ANSI_U: "U", + kVK_ANSI_V: "V", + kVK_ANSI_W: "W", + kVK_ANSI_X: "X", + kVK_ANSI_Y: "Y", + kVK_ANSI_Z: "Z", // Symbols - case kVK_ANSI_LeftBracket: return "[" - case kVK_ANSI_RightBracket: return "]" - case kVK_ANSI_Semicolon: return ";" - case kVK_ANSI_Quote: return "'" - case kVK_ANSI_Comma: return "," - case kVK_ANSI_Period: return "." - case kVK_ANSI_Slash: return "/" - case kVK_ANSI_Backslash: return "\\" - case kVK_ANSI_Minus: return "-" - case kVK_ANSI_Equal: return "=" - case kVK_ANSI_Grave: return "`" - - // Modifiers - case kVK_Command: return "Command" - case kVK_Shift: return "Shift" - case kVK_Option: return "Option" - case kVK_Control: return "Control" - case kVK_CapsLock: return "Caps Lock" - case kVK_Function: return "Fn" + kVK_ANSI_LeftBracket: "[", + kVK_ANSI_RightBracket: "]", + kVK_ANSI_Semicolon: ";", + kVK_ANSI_Quote: "'", + kVK_ANSI_Comma: ",", + kVK_ANSI_Period: ".", + kVK_ANSI_Slash: "/", + kVK_ANSI_Backslash: "\\", + kVK_ANSI_Minus: "-", + kVK_ANSI_Equal: "=", + kVK_ANSI_Grave: "`", + + // Modifiers (Left) + kVK_Command: "Left ⌘", + kVK_Shift: "Left ⇧", + kVK_Option: "Left ⌥", + kVK_Control: "Left ⌃", + + // Modifiers (Right) + kVK_RightCommand: "Right ⌘", + kVK_RightShift: "Right ⇧", + kVK_RightOption: "Right ⌥", + kVK_RightControl: "Right ⌃", + kVK_CapsLock: "Caps Lock", + kVK_Function: "Fn", // Navigation - case kVK_Home: return "Home" - case kVK_End: return "End" - case kVK_PageUp: return "Page Up" - case kVK_PageDown: return "Page Down" + kVK_Home: "Home", + kVK_End: "End", + kVK_PageUp: "Page Up", + kVK_PageDown: "Page Down", // Mouse buttons - case 0xF000: return "Left Click" - case 0xF001: return "Right Click" - case 0xF002: return "Middle Click" - case 0xF003: return "Scroll Up" - case 0xF004: return "Scroll Down" + 0xF000: "Left Click", + 0xF001: "Right Click", + 0xF002: "Middle Click", + 0xF003: "Scroll Up", + 0xF004: "Scroll Down", // Special actions - case 0xF010: return "On-Screen Keyboard" - case 0xF011: return "Laser Pointer" - case 0xF012: return "Controller Lock" - case 0xF013: return "Directory Navigator" - case 0xF014: return "Command Wheel" + 0xF010: "On-Screen Keyboard", + 0xF011: "Laser Pointer", + 0xF012: "Controller Lock", + 0xF013: "Directory Navigator", + 0xF014: "Command Wheel", // Media keys - Playback - case 0xF020: return "Play/Pause" - case 0xF021: return "Next Track" - case 0xF022: return "Previous Track" - case 0xF023: return "Fast Forward" - case 0xF024: return "Rewind" + 0xF020: "Play/Pause", + 0xF021: "Next Track", + 0xF022: "Previous Track", + 0xF023: "Fast Forward", + 0xF024: "Rewind", // Media keys - Volume - case 0xF030: return "Volume Up" - case 0xF031: return "Volume Down" - case 0xF032: return "Mute" + 0xF030: "Volume Up", + 0xF031: "Volume Down", + 0xF032: "Mute", // Media keys - Brightness - case 0xF040: return "Brightness Up" - case 0xF041: return "Brightness Down" + 0xF040: "Brightness Up", + 0xF041: "Brightness Down" + ] - default: return "Key \(keyCode)" - } + /// Returns a human-readable name for a key code + static func displayName(for keyCode: CGKeyCode) -> String { + return displayNames[Int(keyCode)] ?? "Key \(keyCode)" } /// Returns all available key codes for picker UI @@ -356,6 +370,16 @@ enum KeyCodeMapping { options.append(("=", equal)) options.append(("`", grave)) + // Modifier keys (Left / Right distinction) + options.append(("Left ⌘", command)) + options.append(("Right ⌘", rightCommand)) + options.append(("Left ⌥", option)) + options.append(("Right ⌥", rightOption)) + options.append(("Left ⇧", shift)) + options.append(("Right ⇧", rightShift)) + options.append(("Left ⌃", control)) + options.append(("Right ⌃", rightControl)) + // Mouse buttons options.append(("Left Click", mouseLeftClick)) options.append(("Right Click", mouseRightClick)) @@ -415,6 +439,31 @@ enum KeyCodeMapping { isMouseButton(keyCode) || isScrollAction(keyCode) || isSpecialAction(keyCode) || isMediaKey(keyCode) } + /// Checks if a key code represents a modifier key (left or right variant) + static func isModifierKey(_ keyCode: CGKeyCode) -> Bool { + switch Int(keyCode) { + case kVK_Command, kVK_RightCommand, + kVK_Shift, kVK_RightShift, + kVK_Option, kVK_RightOption, + kVK_Control, kVK_RightControl: + return true + default: + return false + } + } + + /// Returns the CGEventFlags mask for a modifier key code, or empty if not a modifier. + /// Left and right variants share the same mask (e.g. both Commands → .maskCommand). + static func modifierFlag(for keyCode: CGKeyCode) -> CGEventFlags { + switch Int(keyCode) { + case kVK_Command, kVK_RightCommand: return .maskCommand + case kVK_Shift, kVK_RightShift: return .maskShift + case kVK_Option, kVK_RightOption: return .maskAlternate + case kVK_Control, kVK_RightControl: return .maskControl + default: return [] + } + } + // MARK: - Character to Key Code Mapping /// Returns the key code and whether shift is required for a given character diff --git a/XboxControllerMapper/XboxControllerMapper/Utilities/VariableExpander.swift b/XboxControllerMapper/XboxControllerMapper/Utilities/VariableExpander.swift index 1f88c927..5f8b9b2b 100644 --- a/XboxControllerMapper/XboxControllerMapper/Utilities/VariableExpander.swift +++ b/XboxControllerMapper/XboxControllerMapper/Utilities/VariableExpander.swift @@ -6,6 +6,8 @@ enum VariableExpander { // MARK: - Variable Definitions + private static let isoFormatter = ISO8601DateFormatter() + /// All supported variables with their descriptions and examples static let availableVariables: [(name: String, description: String, example: String)] = [ // Date formats @@ -80,6 +82,8 @@ enum VariableExpander { // Find all matches (process in reverse to preserve indices) let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) + let formatter = DateFormatter() + for match in matches.reversed() { guard let fullRange = Range(match.range, in: result), let varNameRange = Range(match.range(at: 1), in: result) else { @@ -88,7 +92,7 @@ enum VariableExpander { let variableName = String(result[varNameRange]) - if let value = resolveVariable(variableName) { + if let value = resolveVariable(variableName, formatter: formatter) { result.replaceSubrange(fullRange, with: value) } // If variable cannot be resolved, leave it unchanged @@ -100,9 +104,8 @@ enum VariableExpander { // MARK: - Variable Resolution /// Resolves a single variable name to its value - private static func resolveVariable(_ name: String) -> String? { + private static func resolveVariable(_ name: String, formatter: DateFormatter) -> String? { let now = Date() - let formatter = DateFormatter() switch name { // Date formats @@ -201,7 +204,7 @@ enum VariableExpander { return formatter.string(from: now) case "time.iso": - return ISO8601DateFormatter().string(from: now) + return isoFormatter.string(from: now) case "unix": return String(Int(now.timeIntervalSince1970)) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Components/BookmarkPickerSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/Components/BookmarkPickerSheet.swift index 48af42bf..c362ab72 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Components/BookmarkPickerSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Components/BookmarkPickerSheet.swift @@ -46,6 +46,8 @@ struct BookmarkPickerSheet: View { .foregroundColor(.secondary) } .buttonStyle(.plain) + .help("Clear search") + .accessibilityLabel("Clear search") } } .padding(8) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Components/KeyCaptureField.swift b/XboxControllerMapper/XboxControllerMapper/Views/Components/KeyCaptureField.swift index 4783d52d..c413f45b 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Components/KeyCaptureField.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Components/KeyCaptureField.swift @@ -22,6 +22,7 @@ struct KeyCaptureField: View { .foregroundColor(.secondary) } .buttonStyle(.plain) + .help("Clear shortcut") .accessibilityLabel("Clear shortcut") } } @@ -61,10 +62,10 @@ struct KeyCaptureField: View { private func updateDisplayText() { var parts: [String] = [] - if modifiers.command { parts.append("⌘") } - if modifiers.option { parts.append("⌥") } - if modifiers.shift { parts.append("⇧") } - if modifiers.control { parts.append("⌃") } + if modifiers.command { parts.append(ModifierFlags.label(for: modifiers.commandSide) + "⌘") } + if modifiers.option { parts.append(ModifierFlags.label(for: modifiers.optionSide) + "⌥") } + if modifiers.shift { parts.append(ModifierFlags.label(for: modifiers.shiftSide) + "⇧") } + if modifiers.control { parts.append(ModifierFlags.label(for: modifiers.controlSide) + "⌃") } if let keyCode = keyCode { parts.append(KeyCodeMapping.displayName(for: keyCode)) @@ -120,6 +121,9 @@ class KeyCaptureNSView: NSView { private var currentModifiers = ModifierFlags() private var peakModifiers = ModifierFlags() // Tracks the maximum set of modifiers held private var hasNonModifierKey = false + /// Specific modifier key codes (kVK_Command vs kVK_RightCommand, etc.) seen during this capture. + /// Lets us preserve left/right identity when the user records just one modifier key on its own. + private var capturedModifierKeyCodes: [UInt16] = [] override var acceptsFirstResponder: Bool { true } @@ -130,6 +134,7 @@ class KeyCaptureNSView: NSView { currentModifiers = ModifierFlags() peakModifiers = ModifierFlags() hasNonModifierKey = false + capturedModifierKeyCodes = [] let eventMask: NSEvent.EventTypeMask = [ .keyDown, .flagsChanged, @@ -143,7 +148,7 @@ class KeyCaptureNSView: NSView { // (activation click happens via onTapGesture before monitor starts) if event.type == .leftMouseDown || event.type == .rightMouseDown || event.type == .otherMouseDown { // Check if click is within our bounds; if outside, stop capturing and pass through - if let window = self.window { + if self.window != nil { let locationInView = self.convert(event.locationInWindow, from: nil) if !self.bounds.contains(locationInView) { self.stopCapturing() @@ -177,26 +182,53 @@ class KeyCaptureNSView: NSView { ] if event.type == .flagsChanged { - // Track current modifier state - var mods = ModifierFlags() - if event.modifierFlags.contains(.command) { mods.command = true } - if event.modifierFlags.contains(.option) { mods.option = true } - if event.modifierFlags.contains(.shift) { mods.shift = true } - if event.modifierFlags.contains(.control) { mods.control = true } + let specificKeyCode = event.keyCode + // Remember which specific modifier key codes were touched (left vs right). + if modifierOnlyKeys.contains(Int(specificKeyCode)) && + !self.capturedModifierKeyCodes.contains(specificKeyCode) { + self.capturedModifierKeyCodes.append(specificKeyCode) + } + + // Track current modifier state, preserving side when exactly one side of + // a modifier has been involved in this capture. + let mods = Self.modifierFlags( + from: event.modifierFlags, + capturedKeyCodes: self.capturedModifierKeyCodes + ) self.currentModifiers = mods // Track the peak (maximum) modifiers held during this capture session // This ensures Command+Shift captures both even if released one at a time - if mods.command { self.peakModifiers.command = true } - if mods.option { self.peakModifiers.option = true } - if mods.shift { self.peakModifiers.shift = true } - if mods.control { self.peakModifiers.control = true } + if mods.command { + self.peakModifiers.command = true + self.peakModifiers.commandSide = mods.commandSide + } + if mods.option { + self.peakModifiers.option = true + self.peakModifiers.optionSide = mods.optionSide + } + if mods.shift { + self.peakModifiers.shift = true + self.peakModifiers.shiftSide = mods.shiftSide + } + if mods.control { + self.peakModifiers.control = true + self.peakModifiers.controlSide = mods.controlSide + } // If all modifiers are now released and no regular key was pressed, // capture the peak modifiers as a modifier-only shortcut if !mods.hasAny && self.peakModifiers.hasAny && !self.hasNonModifierKey { - self.onKeyCapture?(nil, self.peakModifiers) + // If the user pressed exactly one modifier key on its own, preserve + // the specific left/right key code (e.g. kVK_RightCommand). Otherwise + // fall back to the mask-based modifier-only shortcut. + let sideAwareCodes = self.capturedModifierKeyCodes.filter { Int($0) != kVK_Function } + if sideAwareCodes.count == 1, let code = sideAwareCodes.first { + self.onKeyCapture?(CGKeyCode(code), ModifierFlags()) + } else { + self.onKeyCapture?(nil, self.peakModifiers) + } self.stopCapturing() } @@ -222,6 +254,64 @@ class KeyCaptureNSView: NSView { } } + static func modifierFlags( + from eventFlags: NSEvent.ModifierFlags, + capturedKeyCodes: [UInt16] + ) -> ModifierFlags { + var modifiers = ModifierFlags( + command: eventFlags.contains(.command), + option: eventFlags.contains(.option), + shift: eventFlags.contains(.shift), + control: eventFlags.contains(.control) + ) + + if modifiers.command { + modifiers.commandSide = capturedSide( + leftKey: kVK_Command, + rightKey: kVK_RightCommand, + capturedKeyCodes: capturedKeyCodes + ) + } + if modifiers.option { + modifiers.optionSide = capturedSide( + leftKey: kVK_Option, + rightKey: kVK_RightOption, + capturedKeyCodes: capturedKeyCodes + ) + } + if modifiers.shift { + modifiers.shiftSide = capturedSide( + leftKey: kVK_Shift, + rightKey: kVK_RightShift, + capturedKeyCodes: capturedKeyCodes + ) + } + if modifiers.control { + modifiers.controlSide = capturedSide( + leftKey: kVK_Control, + rightKey: kVK_RightControl, + capturedKeyCodes: capturedKeyCodes + ) + } + + return modifiers + } + + private static func capturedSide( + leftKey: Int, + rightKey: Int, + capturedKeyCodes: [UInt16] + ) -> ModifierSide? { + let hasLeft = capturedKeyCodes.contains(UInt16(leftKey)) + let hasRight = capturedKeyCodes.contains(UInt16(rightKey)) + + switch (hasLeft, hasRight) { + case (true, false): return .left + case (false, true): return .right + default: return nil + } + } + func stopCapturing() { if let monitor = localMonitor { NSEvent.removeMonitor(monitor) @@ -230,6 +320,7 @@ class KeyCaptureNSView: NSView { currentModifiers = ModifierFlags() peakModifiers = ModifierFlags() hasNonModifierKey = false + capturedModifierKeyCodes = [] } deinit { diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Components/KeyboardVisualView.swift b/XboxControllerMapper/XboxControllerMapper/Views/Components/KeyboardVisualView.swift index c8770921..f0301f95 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Components/KeyboardVisualView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Components/KeyboardVisualView.swift @@ -43,10 +43,10 @@ struct KeyboardVisualView: View { private var modifierRow: some View { HStack(spacing: 16) { - ModifierToggle(label: "⌘ Command", isOn: $modifiers.command) - ModifierToggle(label: "⌥ Option", isOn: $modifiers.option) - ModifierToggle(label: "⇧ Shift", isOn: $modifiers.shift) - ModifierToggle(label: "⌃ Control", isOn: $modifiers.control) + ModifierToggle(label: "⌘ Command", isOn: $modifiers.command, side: $modifiers.commandSide) + ModifierToggle(label: "⌥ Option", isOn: $modifiers.option, side: $modifiers.optionSide) + ModifierToggle(label: "⇧ Shift", isOn: $modifiers.shift, side: $modifiers.shiftSide) + ModifierToggle(label: "⌃ Control", isOn: $modifiers.control, side: $modifiers.controlSide) Spacer() @@ -161,7 +161,7 @@ struct KeyboardVisualView: View { private var zxcvRow: some View { HStack(spacing: 4) { - ModifierKeyButton(label: "⇧ Shift", width: 80, isActive: $modifiers.shift) + ModifierKeyButton(label: "⇧ Shift", width: 80, isActive: $modifiers.shift, physicalSide: .left, activeSide: modifiers.shiftSide) let zxcvKeys = ["Z", "X", "C", "V", "B", "N", "M"] let zxcvCodes: [Int] = [kVK_ANSI_Z, kVK_ANSI_X, kVK_ANSI_C, kVK_ANSI_V, kVK_ANSI_B, kVK_ANSI_N, kVK_ANSI_M] @@ -173,7 +173,7 @@ struct KeyboardVisualView: View { KeyButton(keyCode: CGKeyCode(kVK_ANSI_Comma), label: ",", selectedKeyCode: $selectedKeyCode, hoveredKey: $hoveredKey) KeyButton(keyCode: CGKeyCode(kVK_ANSI_Period), label: ".", selectedKeyCode: $selectedKeyCode, hoveredKey: $hoveredKey) KeyButton(keyCode: CGKeyCode(kVK_ANSI_Slash), label: "/", selectedKeyCode: $selectedKeyCode, hoveredKey: $hoveredKey) - ModifierKeyButton(label: "⇧ Shift", width: 80, isActive: $modifiers.shift) + ModifierKeyButton(label: "⇧ Shift", width: 80, isActive: $modifiers.shift, physicalSide: .right, activeSide: modifiers.shiftSide) } } @@ -191,14 +191,14 @@ struct KeyboardVisualView: View { RoundedRectangle(cornerRadius: 4) .stroke(Color.gray.opacity(0.3), lineWidth: 1) ) - ModifierKeyButton(label: "⌃", width: 40, isActive: $modifiers.control) - ModifierKeyButton(label: "⌥", width: 40, isActive: $modifiers.option) - ModifierKeyButton(label: "⌘", width: 50, isActive: $modifiers.command) + ModifierKeyButton(label: "⌃", width: 40, isActive: $modifiers.control, physicalSide: .left, activeSide: modifiers.controlSide) + ModifierKeyButton(label: "⌥", width: 40, isActive: $modifiers.option, physicalSide: .left, activeSide: modifiers.optionSide) + ModifierKeyButton(label: "⌘", width: 50, isActive: $modifiers.command, physicalSide: .left, activeSide: modifiers.commandSide) KeyButton(keyCode: CGKeyCode(kVK_Space), label: "Space", width: 200, selectedKeyCode: $selectedKeyCode, hoveredKey: $hoveredKey) - ModifierKeyButton(label: "⌘", width: 50, isActive: $modifiers.command) - ModifierKeyButton(label: "⌥", width: 40, isActive: $modifiers.option) + ModifierKeyButton(label: "⌘", width: 50, isActive: $modifiers.command, physicalSide: .right, activeSide: modifiers.commandSide) + ModifierKeyButton(label: "⌥", width: 40, isActive: $modifiers.option, physicalSide: .right, activeSide: modifiers.optionSide) // Arrow keys cluster VStack(spacing: 2) { @@ -327,14 +327,27 @@ struct KeyboardVisualView: View { // MARK: - Modifier Key Button (toggleable) +/// A modifier key on the keyboard layout. Clicking toggles the modifier mask flag. +/// Highlighting reflects the selected side: when `activeSide` is nil ("Any"), both +/// physical positions highlight; when `.left` or `.right` is selected, only the +/// matching physical position highlights. struct ModifierKeyButton: View { let label: String var width: CGFloat = 40 var height: CGFloat = 32 @Binding var isActive: Bool + /// Which physical side of the keyboard this button represents + let physicalSide: ModifierSide + /// Currently selected side for this modifier (nil = Any → highlight both sides) + let activeSide: ModifierSide? @State private var isHovered = false + /// Whether THIS button should appear highlighted given the side selection. + private var isHighlighted: Bool { + isActive && (activeSide == nil || activeSide == physicalSide) + } + var body: some View { Button(action: { isActive.toggle() }) { Text(label) @@ -345,7 +358,7 @@ struct ModifierKeyButton: View { .cornerRadius(4) .overlay( RoundedRectangle(cornerRadius: 4) - .stroke(borderColor, lineWidth: isActive ? 2 : 1) + .stroke(borderColor, lineWidth: isHighlighted ? 2 : 1) ) } .buttonStyle(.plain) @@ -364,7 +377,7 @@ struct ModifierKeyButton: View { } private var backgroundColor: Color { - if isActive { + if isHighlighted { return .accentColor } else if isHovered { return Color.accentColor.opacity(0.3) @@ -374,11 +387,11 @@ struct ModifierKeyButton: View { } private var foregroundColor: Color { - isActive ? .white : .primary + isHighlighted ? .white : .primary } private var borderColor: Color { - if isActive { + if isHighlighted { return .accentColor } else if isHovered { return .accentColor.opacity(0.5) @@ -530,20 +543,61 @@ struct MediaKeyButton: View { // MARK: - Modifier Toggle +/// Checkbox-style toggle for a modifier flag, with an inline L/R side picker +/// that appears once the modifier is active. Side defaults to `nil` (either side +/// — the simulator presses the Left keycode by default). struct ModifierToggle: View { let label: String @Binding var isOn: Bool + @Binding var side: ModifierSide? var body: some View { - Button(action: { isOn.toggle() }) { - HStack(spacing: 4) { - Image(systemName: isOn ? "checkmark.square.fill" : "square") - .foregroundColor(isOn ? .accentColor : .secondary) - Text(label) - .font(.caption) + HStack(spacing: 8) { + Button(action: { + isOn.toggle() + if !isOn { side = nil } + }) { + HStack(spacing: 4) { + Image(systemName: isOn ? "checkmark.square.fill" : "square") + .foregroundColor(isOn ? .accentColor : .secondary) + Text(label) + .font(.caption) + .lineLimit(1) + } } + .buttonStyle(.plain) + + if isOn { + HStack(spacing: 2) { + sideChip(label: "Any", value: nil) + sideChip(label: "L", value: .left) + sideChip(label: "R", value: .right) + } + .transition(.opacity) + } + } + .fixedSize(horizontal: true, vertical: false) + } + + private func sideChip(label: String, value: ModifierSide?) -> some View { + let isSelected = side == value + return Button(action: { side = value }) { + Text(label) + .font(.system(size: 9, weight: .semibold)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(isSelected ? Color.accentColor : Color(nsColor: .controlBackgroundColor)) + .foregroundColor(isSelected ? .white : .secondary) + .cornerRadius(3) + .overlay( + RoundedRectangle(cornerRadius: 3) + .stroke(isSelected ? Color.accentColor : Color.gray.opacity(0.4), lineWidth: 1) + ) } .buttonStyle(.plain) + .help("\(label) \(label == "Any" ? "side" : "side only")") } } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Components/OnScreenKeyboardView.swift b/XboxControllerMapper/XboxControllerMapper/Views/Components/OnScreenKeyboardView.swift index 9df8f18d..7e1833b5 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Components/OnScreenKeyboardView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Components/OnScreenKeyboardView.swift @@ -615,11 +615,11 @@ struct OnScreenKeyboardView: View { Spacer().frame(width: 30) ForEach(0..<4, id: \.self) { i in - clickableKey(CGKeyCode(f13to20Codes[i]), label: "F\(i + 13)", width: 75, keyboardRow: extendedFKeyRowIndex, column: i) + clickableKey(CGKeyCode(f13to20Codes[i]), label: "F\(i + 13)", layout: KeyLayout(width: 75, keyboardRow: extendedFKeyRowIndex, column: i)) } Spacer().frame(width: 19) ForEach(4..<8, id: \.self) { i in - clickableKey(CGKeyCode(f13to20Codes[i]), label: "F\(i + 13)", width: 75, keyboardRow: extendedFKeyRowIndex, column: i) + clickableKey(CGKeyCode(f13to20Codes[i]), label: "F\(i + 13)", layout: KeyLayout(width: 75, keyboardRow: extendedFKeyRowIndex, column: i)) } Spacer().frame(width: 19) ForEach(0..<4, id: \.self) { _ in @@ -630,20 +630,20 @@ struct OnScreenKeyboardView: View { private var functionKeyRow: some View { HStack(spacing: keySpacing) { - clickableKey(CGKeyCode(kVK_Escape), label: "Esc", width: 85, isSpecial: true, keyboardRow: functionKeyRowIndex, column: 0) + clickableKey(CGKeyCode(kVK_Escape), label: "Esc", layout: KeyLayout(width: 85, isSpecial: true, keyboardRow: functionKeyRowIndex, column: 0)) Spacer().frame(width: 30) ForEach(0..<4, id: \.self) { i in - clickableKey(CGKeyCode(f1to12Codes[i]), label: "F\(i + 1)", width: 75, keyboardRow: functionKeyRowIndex, column: i + 1) + clickableKey(CGKeyCode(f1to12Codes[i]), label: "F\(i + 1)", layout: KeyLayout(width: 75, keyboardRow: functionKeyRowIndex, column: i + 1)) } Spacer().frame(width: 19) ForEach(4..<8, id: \.self) { i in - clickableKey(CGKeyCode(f1to12Codes[i]), label: "F\(i + 1)", width: 75, keyboardRow: functionKeyRowIndex, column: i + 1) + clickableKey(CGKeyCode(f1to12Codes[i]), label: "F\(i + 1)", layout: KeyLayout(width: 75, keyboardRow: functionKeyRowIndex, column: i + 1)) } Spacer().frame(width: 19) ForEach(8..<12, id: \.self) { i in - clickableKey(CGKeyCode(f1to12Codes[i]), label: "F\(i + 1)", width: 75, keyboardRow: functionKeyRowIndex, column: i + 1) + clickableKey(CGKeyCode(f1to12Codes[i]), label: "F\(i + 1)", layout: KeyLayout(width: 75, keyboardRow: functionKeyRowIndex, column: i + 1)) } } } @@ -658,17 +658,17 @@ struct OnScreenKeyboardView: View { private var numberRow: some View { HStack(spacing: keySpacing) { - clickableKey(CGKeyCode(kVK_ANSI_Grave), label: "`", keyboardRow: numberRowIndex, column: 0) + clickableKey(CGKeyCode(kVK_ANSI_Grave), label: "`", layout: KeyLayout(keyboardRow: numberRowIndex, column: 0)) ForEach(0..<10, id: \.self) { i in let keyCode = CGKeyCode(numberKeyCodes[i]) let displayNum = i == 9 ? "0" : "\(i + 1)" - clickableKey(keyCode, label: displayNum, keyboardRow: numberRowIndex, column: i + 1) + clickableKey(keyCode, label: displayNum, layout: KeyLayout(keyboardRow: numberRowIndex, column: i + 1)) } - clickableKey(CGKeyCode(kVK_ANSI_Minus), label: "-", keyboardRow: numberRowIndex, column: 11) - clickableKey(CGKeyCode(kVK_ANSI_Equal), label: "=", keyboardRow: numberRowIndex, column: 12) - clickableKey(CGKeyCode(kVK_Delete), label: "⌫", width: 107, isSpecial: true, keyboardRow: numberRowIndex, column: 13) + clickableKey(CGKeyCode(kVK_ANSI_Minus), label: "-", layout: KeyLayout(keyboardRow: numberRowIndex, column: 11)) + clickableKey(CGKeyCode(kVK_ANSI_Equal), label: "=", layout: KeyLayout(keyboardRow: numberRowIndex, column: 12)) + clickableKey(CGKeyCode(kVK_Delete), label: "⌫", layout: KeyLayout(width: 107, isSpecial: true, keyboardRow: numberRowIndex, column: 13)) } } @@ -676,18 +676,18 @@ struct OnScreenKeyboardView: View { private var qwertyRow: some View { HStack(spacing: keySpacing) { - clickableKey(CGKeyCode(kVK_Tab), label: "Tab", width: 95, isSpecial: true, keyboardRow: qwertyRowIndex, column: 0) + clickableKey(CGKeyCode(kVK_Tab), label: "Tab", layout: KeyLayout(width: 95, isSpecial: true, keyboardRow: qwertyRowIndex, column: 0)) let qwertyKeys = ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"] let qwertyCodes: [Int] = [kVK_ANSI_Q, kVK_ANSI_W, kVK_ANSI_E, kVK_ANSI_R, kVK_ANSI_T, kVK_ANSI_Y, kVK_ANSI_U, kVK_ANSI_I, kVK_ANSI_O, kVK_ANSI_P] ForEach(0.. some View { - let actualWidth = width ?? keyWidth - let actualHeight = height ?? keyHeight + private func clickableKey(_ keyCode: CGKeyCode, label: String, layout: KeyLayout = KeyLayout()) -> some View { + let actualWidth = layout.width ?? keyWidth + let actualHeight = layout.height ?? keyHeight let isNavHighlighted: Bool = { - guard let row = keyboardRow, let col = column else { return false } + guard let row = layout.keyboardRow, let col = layout.column else { return false } return keyboardManager.highlightedItem == .keyPosition(row: row, column: col) }() let isControllerPressed: Bool = { - guard let row = keyboardRow, let col = column else { return false } + guard let row = layout.keyboardRow, let col = layout.column else { return false } return keyboardManager.controllerPressedItem == .keyPosition(row: row, column: col) }() let isHovered = hoveredKey == keyCode && !keyboardManager.navigationModeActive @@ -826,22 +834,22 @@ struct OnScreenKeyboardView: View { } else { Text(label) .font(.system(size: fontSize(for: label), weight: .bold)) - .foregroundColor(isSpecial ? .accentColor : .white) - .opacity(isSpecial && !isHighlighted ? 0.9 : 1.0) + .foregroundColor(layout.isSpecial ? .accentColor : .white) + .opacity(layout.isSpecial && !isHighlighted ? 0.9 : 1.0) } } .frame(width: actualWidth, height: actualHeight) - .background(GlassKeyBackground(isHovered: isHovered, isPressed: isPressed, isSpecial: isSpecial, specialColor: .orange, isNavHighlighted: isNavHighlighted)) + .background(GlassKeyBackground(isHovered: isHovered, isPressed: isPressed, isSpecial: layout.isSpecial, specialColor: .orange, isNavHighlighted: isNavHighlighted)) .cornerRadius(8) .scaleEffect(isPressed ? 0.95 : (isHighlighted ? 1.05 : 1.0)) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHighlighted) } .buttonStyle(.plain) - .ifLet(keyboardRow, column) { $0.navigationItemBounds(.keyPosition(row: $1, column: $2)) } + .ifLet(layout.keyboardRow, layout.column) { $0.navigationItemBounds(.keyPosition(row: $1, column: $2)) } .onHover { hovering in hoveredKey = hovering ? keyCode : nil if hovering { - if let row = keyboardRow, let col = column { + if let row = layout.keyboardRow, let col = layout.column { keyboardManager.setMouseHoveredKeyPosition(row: row, column: col) } else { keyboardManager.setMouseHoveredKey(keyCode) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroEditorSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroEditorSheet.swift index 8ba6272f..cc90d386 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroEditorSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroEditorSheet.swift @@ -286,6 +286,7 @@ struct MacroStepRow: View { } .buttonStyle(.plain) .help("Duplicate step") + .accessibilityLabel("Duplicate step") Button { onDelete() @@ -296,6 +297,7 @@ struct MacroStepRow: View { } .buttonStyle(.plain) .help("Delete step") + .accessibilityLabel("Delete step") Image(systemName: "chevron.right") .font(.caption) @@ -645,7 +647,10 @@ struct MacroStepEditorSheet: View { Text(webhookHeaders[key] ?? "").font(.caption).foregroundColor(.secondary) Button { webhookHeaders.removeValue(forKey: key) } label: { Image(systemName: "minus.circle.fill").foregroundColor(.red) - }.buttonStyle(.plain) + } + .buttonStyle(.plain) + .help("Remove header") + .accessibilityLabel("Remove header") } } HStack { @@ -663,6 +668,8 @@ struct MacroStepEditorSheet: View { Image(systemName: "plus.circle.fill").foregroundColor(.green) } .buttonStyle(.plain) + .help("Add header") + .accessibilityLabel("Add header") .disabled(newWebhookHeaderKey.isEmpty) } } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroListView.swift b/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroListView.swift index 6ec4d305..f2e1f62f 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroListView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroListView.swift @@ -103,6 +103,7 @@ struct MacroRow: View { .foregroundColor(.accentColor) } .buttonStyle(.borderless) + .help("Edit") .accessibilityLabel("Edit") Button(action: onDelete) { @@ -110,6 +111,7 @@ struct MacroRow: View { .foregroundColor(.red.opacity(0.8)) } .buttonStyle(.borderless) + .help("Delete") .accessibilityLabel("Delete") } } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ButtonMappingSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ButtonMappingSheet.swift index 95375ea4..c194e558 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ButtonMappingSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ButtonMappingSheet.swift @@ -274,6 +274,8 @@ struct ButtonMappingSheet: View { .foregroundColor(.secondary) } .buttonStyle(.plain) + .help("Close") + .accessibilityLabel("Close") } .padding(16) } @@ -383,7 +385,21 @@ struct ButtonMappingSheet: View { .onChange(of: primaryState.keyCode) { _, newValue in guard !isLoading else { return } - if let code = newValue, KeyCodeMapping.isMouseButton(code) || KeyCodeMapping.isSpecialAction(code) { + if let code = newValue, KeyCodeMapping.isModifierKey(code) { + // Modifier keys: auto-enable hold (so the modifier stays pressed while + // the controller button is held) and disable long hold / double tap / repeat + // which don't make sense for a sticky modifier. + if !userHasInteractedWithHold { + isHoldModifier = true + } + enableLongHold = false + enableDoubleTap = false + enableRepeat = false + longHoldState.keyCode = nil + longHoldState.modifiers = ModifierFlags() + doubleTapState.keyCode = nil + doubleTapState.modifiers = ModifierFlags() + } else if let code = newValue, KeyCodeMapping.isMouseButton(code) || KeyCodeMapping.isSpecialAction(code) { // Mouse clicks and special actions: auto-enable hold and disable long hold/double tap/repeat // Exception: visual tools default to toggle mode (isHoldModifier = false) if !userHasInteractedWithHold { diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/CommandWheelSettingsView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/CommandWheelSettingsView.swift index 07c9c011..6d07acf0 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/CommandWheelSettingsView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/CommandWheelSettingsView.swift @@ -175,6 +175,8 @@ struct CommandWheelActionRow: View { } .buttonStyle(.plain) .foregroundColor(.secondary) + .help("Edit") + .accessibilityLabel("Edit Command Wheel Action") // Delete button Button(action: onDelete) { @@ -183,6 +185,8 @@ struct CommandWheelActionRow: View { } .buttonStyle(.plain) .foregroundColor(.secondary) + .help("Delete") + .accessibilityLabel("Delete Command Wheel Action") } .padding(.vertical, 4) .padding(.horizontal, 6) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentView.swift index 3bc3cf3e..8404ba65 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentView.swift @@ -316,8 +316,9 @@ struct ContentView: View { } private func selectFirstVisibleTabIfNeeded() { - guard !orderedTabTags.contains(selectedTab), - let firstVisibleTag = orderedTabTags.first else { + let tabs = customTabs + guard !tabs.contains(where: { $0.tag == selectedTab }), + let firstVisibleTag = tabs.first?.tag else { return } selectedTab = firstVisibleTag diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LinkedAppsSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LinkedAppsSheet.swift index d2049cc2..97ff8a3f 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LinkedAppsSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LinkedAppsSheet.swift @@ -57,6 +57,8 @@ struct LinkedAppsSheet: View { .foregroundColor(.red) } .buttonStyle(.borderless) + .help("Remove") + .accessibilityLabel("Remove Linked App") } .padding(.vertical, 4) } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LinkedControllersSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LinkedControllersSheet.swift index 257c8345..46ef3577 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LinkedControllersSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LinkedControllersSheet.swift @@ -56,6 +56,8 @@ struct LinkedControllersSheet: View { .foregroundColor(.red) } .buttonStyle(.borderless) + .help("Remove") + .accessibilityLabel("Remove Linked Controller") } .padding(.vertical, 4) } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/MappingEditorState.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/MappingEditorState.swift index 4d514377..1a3c6ce0 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/MappingEditorState.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/MappingEditorState.swift @@ -70,10 +70,10 @@ struct MappingEditorState { var mappingDisplayString: String { var parts: [String] = [] - if modifiers.command { parts.append("\u{2318}") } - if modifiers.option { parts.append("\u{2325}") } - if modifiers.shift { parts.append("\u{21E7}") } - if modifiers.control { parts.append("\u{2303}") } + if modifiers.command { parts.append(ModifierFlags.label(for: modifiers.commandSide) + "\u{2318}") } + if modifiers.option { parts.append(ModifierFlags.label(for: modifiers.optionSide) + "\u{2325}") } + if modifiers.shift { parts.append(ModifierFlags.label(for: modifiers.shiftSide) + "\u{21E7}") } + if modifiers.control { parts.append(ModifierFlags.label(for: modifiers.controlSide) + "\u{2303}") } if let keyCode = keyCode { parts.append(KeyCodeMapping.displayName(for: keyCode)) } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/OnScreenKeyboardSettingsView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/OnScreenKeyboardSettingsView.swift index e33a1720..1e6725f3 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/OnScreenKeyboardSettingsView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/OnScreenKeyboardSettingsView.swift @@ -106,12 +106,16 @@ struct QuickTextRowView: View { Image(systemName: "pencil") } .buttonStyle(.borderless) + .help("Edit quick text") + .accessibilityLabel("Edit quick text") Button(action: onDelete) { Image(systemName: "trash") .foregroundColor(.red) } .buttonStyle(.borderless) + .help("Delete quick text") + .accessibilityLabel("Delete quick text") } } @@ -181,12 +185,16 @@ struct AppBarItemRowView: View { Image(systemName: "pencil") } .buttonStyle(.borderless) + .help("Edit app") + .accessibilityLabel("Edit app") Button(action: onDelete) { Image(systemName: "trash") .foregroundColor(.red) } .buttonStyle(.borderless) + .help("Delete app") + .accessibilityLabel("Delete app") } .padding(.vertical, 4) .padding(.horizontal, 8) @@ -256,12 +264,16 @@ struct WebsiteLinkRowView: View { Image(systemName: "pencil") } .buttonStyle(.borderless) + .help("Edit link") + .accessibilityLabel("Edit link") Button(action: onDelete) { Image(systemName: "trash") .foregroundColor(.red) } .buttonStyle(.borderless) + .help("Delete link") + .accessibilityLabel("Delete link") } .padding(.vertical, 4) .padding(.horizontal, 8) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SwipeTypingSection.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SwipeTypingSection.swift index 77b9fb21..f33aa408 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SwipeTypingSection.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SwipeTypingSection.swift @@ -7,7 +7,7 @@ struct SwipeTypingSection: View { // Custom words state @State private var newCustomWord = "" - @State private var customWords: [String] = [] + @State private var customWords: Set = [] var body: some View { Section("Swipe Typing") { @@ -87,7 +87,7 @@ struct SwipeTypingSection: View { } if !customWords.isEmpty { - ForEach(customWords, id: \.self) { word in + ForEach(Array(customWords).sorted(), id: \.self) { word in HStack { Text(word) .font(.body.monospaced()) @@ -99,6 +99,8 @@ struct SwipeTypingSection: View { .foregroundColor(.red) } .buttonStyle(.borderless) + .help("Delete") + .accessibilityLabel("Delete Custom Word") } } } @@ -136,9 +138,10 @@ struct SwipeTypingSection: View { customWords = [] return } - customWords = content.components(separatedBy: .newlines) + let words = content.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } + customWords = Set(words) } private func addCustomWord() { @@ -148,14 +151,14 @@ struct SwipeTypingSection: View { newCustomWord = "" return } - customWords.append(word) + customWords.insert(word) saveCustomWords() newCustomWord = "" reloadSwipeModel() } private func removeCustomWord(_ word: String) { - customWords.removeAll { $0 == word } + customWords.remove(word) saveCustomWords() reloadSwipeModel() } @@ -164,7 +167,7 @@ struct SwipeTypingSection: View { let url = customWordsFileURL let dir = url.deletingLastPathComponent() try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try? AtomicFileWriter.write(customWords.joined(separator: "\n"), to: url) + try? AtomicFileWriter.write(Array(customWords).sorted().joined(separator: "\n"), to: url) } private func reloadSwipeModel() { diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptEditorSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptEditorSheet.swift index 7dc82448..cdd8876d 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptEditorSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptEditorSheet.swift @@ -339,7 +339,7 @@ private class MockInputSimulator: InputSimulatorProtocol { func moveMouseNative(dx: Int, dy: Int) {} func warpMouseTo(point: CGPoint) {} var isLeftMouseButtonHeld: Bool { false } - func scroll(dx: CGFloat, dy: CGFloat, phase: CGScrollPhase?, momentumPhase: CGMomentumScrollPhase?, isContinuous: Bool, flags: CGEventFlags) {} + func scroll(event: ScrollEvent) {} func executeMapping(_ mapping: KeyMapping) {} func startHoldMapping(_ mapping: KeyMapping) {} func stopHoldMapping(_ mapping: KeyMapping) {} diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptListView.swift b/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptListView.swift index 9af65f02..40c6f5ff 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptListView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptListView.swift @@ -222,6 +222,7 @@ struct ScriptRow: View { .foregroundColor(.accentColor) } .buttonStyle(.borderless) + .help("Edit") .accessibilityLabel("Edit") Button(action: onDelete) { @@ -229,6 +230,7 @@ struct ScriptRow: View { .foregroundColor(.red.opacity(0.8)) } .buttonStyle(.borderless) + .help("Delete") .accessibilityLabel("Delete") } } diff --git a/XboxControllerMapper/XboxControllerMapperTests/ButtonInteractionFlowPolicyTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ButtonInteractionFlowPolicyTests.swift index 6fe6d2d4..206bec0b 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ButtonInteractionFlowPolicyTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ButtonInteractionFlowPolicyTests.swift @@ -163,4 +163,14 @@ final class ButtonInteractionFlowPolicyTests: XCTestCase { isChordPart: true )) } + + func testRealtimeHoldPathReturnsFalseForOtherLayerActivatorPresses() { + let mapping = KeyMapping(keyCode: 4) + + XCTAssertFalse(ButtonInteractionFlowPolicy.shouldUseRealtimeHoldPath( + mapping: mapping, + isChordPart: false, + isOtherLayerActivatorPress: true + )) + } } diff --git a/XboxControllerMapper/XboxControllerMapperTests/ButtonPressOrchestrationPolicyTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ButtonPressOrchestrationPolicyTests.swift index ba1ca272..6d2345dd 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ButtonPressOrchestrationPolicyTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ButtonPressOrchestrationPolicyTests.swift @@ -106,4 +106,56 @@ final class ButtonPressOrchestrationPolicyTests: XCTestCase { ) ) } + + func testResolveKeepsOtherLayerActivatorPressesOnStandardPathInRealtimeMode() { + let mapping = KeyMapping(keyCode: KeyCodeMapping.pageDown) + let outcome = ButtonPressOrchestrationPolicy.resolve( + button: .rightBumper, + mapping: mapping, + keyboardVisible: false, + navigationModeActive: false, + directoryNavigatorVisible: false, + isChordPart: false, + isOtherLayerActivatorPress: true, + lastTap: nil, + inputLatencyMode: .realtime + ) + + XCTAssertEqual( + outcome, + .mapping( + ButtonPressOrchestrationPolicy.MappingContext( + mapping: mapping, + lastTap: nil, + shouldTreatAsHold: false + ) + ) + ) + } + + func testResolveKeepsRegularRealtimeKeyMappingsOnHoldPath() { + let mapping = KeyMapping(keyCode: KeyCodeMapping.pageDown) + let outcome = ButtonPressOrchestrationPolicy.resolve( + button: .a, + mapping: mapping, + keyboardVisible: false, + navigationModeActive: false, + directoryNavigatorVisible: false, + isChordPart: false, + isOtherLayerActivatorPress: false, + lastTap: nil, + inputLatencyMode: .realtime + ) + + XCTAssertEqual( + outcome, + .mapping( + ButtonPressOrchestrationPolicy.MappingContext( + mapping: mapping, + lastTap: nil, + shouldTreatAsHold: true + ) + ) + ) + } } diff --git a/XboxControllerMapper/XboxControllerMapperTests/InputSimulatorBugTests.swift b/XboxControllerMapper/XboxControllerMapperTests/InputSimulatorBugTests.swift index 1c262dfa..455b5ef7 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/InputSimulatorBugTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/InputSimulatorBugTests.swift @@ -245,12 +245,12 @@ final class ZoomAccumulatorThreadSafetyTests: XCTestCase { DispatchQueue.concurrentPerform(iterations: iterations) { i in let dy = CGFloat(i % 2 == 0 ? 5 : -5) // The Control flag triggers the handleAccessibilityZoom path - simulator.scroll( + simulator.scroll(event: ScrollEvent( dx: 0, dy: dy, phase: nil, momentumPhase: nil, isContinuous: false, flags: .maskControl - ) + )) } // No crash = success. The accumulator should be in a valid state. @@ -265,11 +265,11 @@ final class ZoomAccumulatorThreadSafetyTests: XCTestCase { DispatchQueue.concurrentPerform(iterations: iterations) { i in if i % 3 == 0 { - simulator.scroll(dx: 0, dy: 5, phase: nil, momentumPhase: nil, - isContinuous: false, flags: .maskControl) + simulator.scroll(event: ScrollEvent(dx: 0, dy: 5, phase: nil, momentumPhase: nil, + isContinuous: false, flags: .maskControl)) } else { - simulator.scroll(dx: 0, dy: 3, phase: nil, momentumPhase: nil, - isContinuous: false, flags: []) + simulator.scroll(event: ScrollEvent(dx: 0, dy: 3, phase: nil, momentumPhase: nil, + isContinuous: false, flags: [])) } } @@ -306,12 +306,12 @@ final class ZoomWarningStateMachineTests: XCTestCase { // Simulate many control+scroll calls (each increments zoomAttemptCount) // After 5 attempts, warning would be shown and shortcuts stop being sent. for _ in 0..<20 { - simulator.scroll( + simulator.scroll(event: ScrollEvent( dx: 0, dy: 15, // Above threshold to trigger zoom step phase: nil, momentumPhase: nil, isContinuous: false, flags: .maskControl - ) + )) // Small delay to pass rate limiting usleep(60_000) // 60ms > 50ms minimum interval } diff --git a/XboxControllerMapper/XboxControllerMapperTests/KeyCodeMappingDisplayTests.swift b/XboxControllerMapper/XboxControllerMapperTests/KeyCodeMappingDisplayTests.swift index ae44e5ad..1a618fb2 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/KeyCodeMappingDisplayTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/KeyCodeMappingDisplayTests.swift @@ -94,6 +94,58 @@ final class KeyCodeMappingDisplayTests: XCTestCase { XCTAssertFalse(KeyCodeMapping.isMouseButton(KeyCodeMapping.scrollDown)) } + func testDisplayName_DistinguishesLeftAndRightModifiers() { + XCTAssertEqual(KeyCodeMapping.displayName(for: CGKeyCode(kVK_Command)), "Left ⌘") + XCTAssertEqual(KeyCodeMapping.displayName(for: CGKeyCode(kVK_RightCommand)), "Right ⌘") + XCTAssertEqual(KeyCodeMapping.displayName(for: CGKeyCode(kVK_Option)), "Left ⌥") + XCTAssertEqual(KeyCodeMapping.displayName(for: CGKeyCode(kVK_RightOption)), "Right ⌥") + XCTAssertEqual(KeyCodeMapping.displayName(for: CGKeyCode(kVK_Shift)), "Left ⇧") + XCTAssertEqual(KeyCodeMapping.displayName(for: CGKeyCode(kVK_RightShift)), "Right ⇧") + XCTAssertEqual(KeyCodeMapping.displayName(for: CGKeyCode(kVK_Control)), "Left ⌃") + XCTAssertEqual(KeyCodeMapping.displayName(for: CGKeyCode(kVK_RightControl)), "Right ⌃") + } + + func testIsModifierKey_AcceptsLeftAndRightVariants() { + XCTAssertTrue(KeyCodeMapping.isModifierKey(CGKeyCode(kVK_Command))) + XCTAssertTrue(KeyCodeMapping.isModifierKey(CGKeyCode(kVK_RightCommand))) + XCTAssertTrue(KeyCodeMapping.isModifierKey(CGKeyCode(kVK_Shift))) + XCTAssertTrue(KeyCodeMapping.isModifierKey(CGKeyCode(kVK_RightShift))) + XCTAssertTrue(KeyCodeMapping.isModifierKey(CGKeyCode(kVK_Option))) + XCTAssertTrue(KeyCodeMapping.isModifierKey(CGKeyCode(kVK_RightOption))) + XCTAssertTrue(KeyCodeMapping.isModifierKey(CGKeyCode(kVK_Control))) + XCTAssertTrue(KeyCodeMapping.isModifierKey(CGKeyCode(kVK_RightControl))) + + XCTAssertFalse(KeyCodeMapping.isModifierKey(KeyCodeMapping.keyA)) + XCTAssertFalse(KeyCodeMapping.isModifierKey(CGKeyCode(kVK_CapsLock))) + XCTAssertFalse(KeyCodeMapping.isModifierKey(CGKeyCode(kVK_Function))) + XCTAssertFalse(KeyCodeMapping.isModifierKey(KeyCodeMapping.mouseLeftClick)) + } + + func testModifierFlag_CollapsesLeftAndRightToSameMask() { + XCTAssertEqual(KeyCodeMapping.modifierFlag(for: CGKeyCode(kVK_Command)), .maskCommand) + XCTAssertEqual(KeyCodeMapping.modifierFlag(for: CGKeyCode(kVK_RightCommand)), .maskCommand) + XCTAssertEqual(KeyCodeMapping.modifierFlag(for: CGKeyCode(kVK_Shift)), .maskShift) + XCTAssertEqual(KeyCodeMapping.modifierFlag(for: CGKeyCode(kVK_RightShift)), .maskShift) + XCTAssertEqual(KeyCodeMapping.modifierFlag(for: CGKeyCode(kVK_Option)), .maskAlternate) + XCTAssertEqual(KeyCodeMapping.modifierFlag(for: CGKeyCode(kVK_RightOption)), .maskAlternate) + XCTAssertEqual(KeyCodeMapping.modifierFlag(for: CGKeyCode(kVK_Control)), .maskControl) + XCTAssertEqual(KeyCodeMapping.modifierFlag(for: CGKeyCode(kVK_RightControl)), .maskControl) + + XCTAssertEqual(KeyCodeMapping.modifierFlag(for: KeyCodeMapping.keyA), []) + XCTAssertEqual(KeyCodeMapping.modifierFlag(for: CGKeyCode(kVK_Function)), []) + } + + func testAllKeyOptions_IncludesLeftAndRightModifiers() { + let options = KeyCodeMapping.allKeyOptions + XCTAssertTrue(options.contains(where: { $0.code == CGKeyCode(kVK_Command) }), + "Picker should expose Left ⌘ as a selectable option") + XCTAssertTrue(options.contains(where: { $0.code == CGKeyCode(kVK_RightCommand) }), + "Picker should expose Right ⌘ as a selectable option") + XCTAssertTrue(options.contains(where: { $0.code == CGKeyCode(kVK_RightShift) })) + XCTAssertTrue(options.contains(where: { $0.code == CGKeyCode(kVK_RightOption) })) + XCTAssertTrue(options.contains(where: { $0.code == CGKeyCode(kVK_RightControl) })) + } + func testSpecialMarkerClassifiers() { XCTAssertTrue(KeyCodeMapping.isMouseButton(KeyCodeMapping.mouseLeftClick)) XCTAssertTrue(KeyCodeMapping.isMouseButton(KeyCodeMapping.mouseRightClick)) diff --git a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineLayerAndLifecycleTests.swift b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineLayerAndLifecycleTests.swift index fd0a1bae..428363b5 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineLayerAndLifecycleTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineLayerAndLifecycleTests.swift @@ -293,6 +293,96 @@ final class MappingEngineLayerAndLifecycleTests: XCTestCase { XCTAssertEqual(layer2Count, 0, "A second layer activator should not activate while a different layer is active") } + func testRealtimeOtherLayerActivatorPressUsesTapPathWhenDifferentLayerActive() async throws { + let layer1 = Layer( + name: "First", + activatorButton: .leftBumper, + buttonMappings: [.rightBumper: .key(KeyCodeMapping.pageDown)] + ) + let layer2 = Layer(name: "Second", activatorButton: .rightBumper, buttonMappings: [.a: .key(62)]) + await MainActor.run { + let profile = Profile( + name: "RealtimeMultiLayer", + buttonMappings: [:], + inputLatencyMode: .realtime, + layers: [layer1, layer2] + ) + installActiveProfile(profile) + } + try? await Task.sleep(nanoseconds: 10_000_000) + + await MainActor.run { + controllerService.buttonPressed(.leftBumper) + } + await waitForTasks(0.1) + await MainActor.run { + mockInputSimulator.clearEvents() + controllerService.buttonPressed(.rightBumper) + controllerService.buttonReleased(.rightBumper) + } + await waitForTasks(0.1) + + let events = mockInputSimulator.events + let tapCount = events.filter { + if case .pressKey(let keyCode, _) = $0 { return keyCode == KeyCodeMapping.pageDown } + return false + }.count + let holdStartCount = events.filter { + if case .startHoldMapping(let mapping) = $0 { return mapping.keyCode == KeyCodeMapping.pageDown } + return false + }.count + + XCTAssertEqual(tapCount, 1, "Remapped layer activator should match standard tap semantics in realtime mode") + XCTAssertEqual(holdStartCount, 0, "Remapped layer activator must not enter realtime held-key path") + } + + func testRealtimeRegularLayerRemapStillUsesHoldPathWhenLayerActive() async throws { + let layer = Layer( + name: "First", + activatorButton: .leftBumper, + buttonMappings: [.a: .key(KeyCodeMapping.pageDown)] + ) + await MainActor.run { + let profile = Profile( + name: "RealtimeLayer", + buttonMappings: [:], + inputLatencyMode: .realtime, + layers: [layer] + ) + installActiveProfile(profile) + } + try? await Task.sleep(nanoseconds: 10_000_000) + + await MainActor.run { + controllerService.buttonPressed(.leftBumper) + } + await waitForTasks(0.1) + await MainActor.run { + mockInputSimulator.clearEvents() + controllerService.buttonPressed(.a) + controllerService.buttonReleased(.a) + } + await waitForTasks(0.1) + + let events = mockInputSimulator.events + let tapCount = events.filter { + if case .pressKey(let keyCode, _) = $0 { return keyCode == KeyCodeMapping.pageDown } + return false + }.count + let holdStartCount = events.filter { + if case .startHoldMapping(let mapping) = $0 { return mapping.keyCode == KeyCodeMapping.pageDown } + return false + }.count + let holdStopCount = events.filter { + if case .stopHoldMapping(let mapping) = $0 { return mapping.keyCode == KeyCodeMapping.pageDown } + return false + }.count + + XCTAssertEqual(tapCount, 0, "Regular layer remaps should keep realtime held-key semantics") + XCTAssertEqual(holdStartCount, 1, "Regular layer remap should enter realtime held-key path") + XCTAssertEqual(holdStopCount, 1, "Regular layer remap should leave realtime held-key path on release") + } + /// Test 6: The layer activator button itself does not emit its own key mapping. func testLayerActivator_buttonDoesNotFireOwnMapping() async throws { let layer = Layer(name: "Activator", activatorButton: .leftBumper, buttonMappings: [.a: .key(50)]) diff --git a/XboxControllerMapper/XboxControllerMapperTests/ModifierFlagsSideTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ModifierFlagsSideTests.swift new file mode 100644 index 00000000..c82eaf29 --- /dev/null +++ b/XboxControllerMapper/XboxControllerMapperTests/ModifierFlagsSideTests.swift @@ -0,0 +1,241 @@ +import XCTest +import CoreGraphics +import Carbon.HIToolbox +import AppKit +@testable import ControllerKeys + +final class ModifierFlagsSideTests: XCTestCase { + + func testVirtualKey_DefaultsToLeftWhenNoSideSet() { + let flags = ModifierFlags(command: true, option: true, shift: true, control: true) + XCTAssertEqual(flags.virtualKey(forMask: .maskCommand), CGKeyCode(kVK_Command)) + XCTAssertEqual(flags.virtualKey(forMask: .maskAlternate), CGKeyCode(kVK_Option)) + XCTAssertEqual(flags.virtualKey(forMask: .maskShift), CGKeyCode(kVK_Shift)) + XCTAssertEqual(flags.virtualKey(forMask: .maskControl), CGKeyCode(kVK_Control)) + } + + func testVirtualKey_HonorsRightSide() { + let flags = ModifierFlags( + command: true, option: true, shift: true, control: true, + commandSide: .right, optionSide: .right, shiftSide: .right, controlSide: .right + ) + XCTAssertEqual(flags.virtualKey(forMask: .maskCommand), CGKeyCode(kVK_RightCommand)) + XCTAssertEqual(flags.virtualKey(forMask: .maskAlternate), CGKeyCode(kVK_RightOption)) + XCTAssertEqual(flags.virtualKey(forMask: .maskShift), CGKeyCode(kVK_RightShift)) + XCTAssertEqual(flags.virtualKey(forMask: .maskControl), CGKeyCode(kVK_RightControl)) + } + + func testVirtualKey_HonorsExplicitLeftSide() { + let flags = ModifierFlags(command: true, commandSide: .left) + XCTAssertEqual(flags.virtualKey(forMask: .maskCommand), CGKeyCode(kVK_Command)) + } + + func testCgEventFlags_IgnoresSide() { + // OS-level mask is the same regardless of side + let leftFlags = ModifierFlags(command: true, commandSide: .left) + let rightFlags = ModifierFlags(command: true, commandSide: .right) + let unspecifiedFlags = ModifierFlags(command: true) + XCTAssertEqual(leftFlags.cgEventFlags, .maskCommand) + XCTAssertEqual(rightFlags.cgEventFlags, .maskCommand) + XCTAssertEqual(unspecifiedFlags.cgEventFlags, .maskCommand) + } + + func testLabel_FormatsSidePrefix() { + XCTAssertEqual(ModifierFlags.label(for: nil), "") + XCTAssertEqual(ModifierFlags.label(for: .left), "L") + XCTAssertEqual(ModifierFlags.label(for: .right), "R") + } + + // MARK: - Codable backward compatibility + + func testDecode_LegacyJsonWithoutSideFields() throws { + // JSON shape from before this feature — no *Side keys + let legacy = """ + {"command": true, "option": false, "shift": true, "control": false} + """.data(using: .utf8)! + + let flags = try JSONDecoder().decode(ModifierFlags.self, from: legacy) + XCTAssertTrue(flags.command) + XCTAssertFalse(flags.option) + XCTAssertTrue(flags.shift) + XCTAssertFalse(flags.control) + XCTAssertNil(flags.commandSide) + XCTAssertNil(flags.optionSide) + XCTAssertNil(flags.shiftSide) + XCTAssertNil(flags.controlSide) + } + + func testDecode_NewJsonWithSideFields() throws { + let json = """ + { + "command": true, "option": false, "shift": true, "control": false, + "commandSide": "right", "shiftSide": "left" + } + """.data(using: .utf8)! + + let flags = try JSONDecoder().decode(ModifierFlags.self, from: json) + XCTAssertEqual(flags.commandSide, .right) + XCTAssertEqual(flags.shiftSide, .left) + XCTAssertNil(flags.optionSide) + XCTAssertNil(flags.controlSide) + } + + func testRoundTrip_SidesPreserved() throws { + let original = ModifierFlags( + command: true, option: true, shift: false, control: false, + commandSide: .right, optionSide: .left + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ModifierFlags.self, from: data) + XCTAssertEqual(original, decoded) + } + + func testHoldModifiers_UsesSideAwareKeyCodesInPressAndReleaseOrder() { + let simulator = RecordingInputSimulator() + let flags = ModifierFlags( + command: true, + option: true, + commandSide: .right, + optionSide: .left + ) + + simulator.holdModifiers(flags) + simulator.releaseModifiers(flags) + + XCTAssertEqual(simulator.events, [ + .holdModifierKey(CGKeyCode(kVK_RightCommand)), + .holdModifierKey(CGKeyCode(kVK_Option)), + .releaseModifierKey(CGKeyCode(kVK_Option)), + .releaseModifierKey(CGKeyCode(kVK_RightCommand)) + ]) + } + + func testModifierTapActionCommand_PreservesRightSide() { + let simulator = RecordingInputSimulator() + let queue = DispatchQueue(label: "test.modifierTap.side") + let action = KeyMapping(modifiers: ModifierFlags(command: true, commandSide: .right)) + let command = ModifierTapActionCommand( + modifiers: action.modifiers, + inputSimulator: simulator, + inputQueue: queue, + action: action + ) + + _ = command.execute() + + let releaseFinished = expectation(description: "modifier release") + queue.asyncAfter(deadline: .now() + Config.modifierReleaseCheckDelay + 0.02) { + releaseFinished.fulfill() + } + wait(for: [releaseFinished], timeout: 1) + + XCTAssertEqual(simulator.events, [ + .holdModifierKey(CGKeyCode(kVK_RightCommand)), + .releaseModifierKey(CGKeyCode(kVK_RightCommand)) + ]) + } + + @MainActor + func testKeyCaptureModifierFlags_PreservesSidesForModifierKeyShortcut() { + let modifiers = KeyCaptureNSView.modifierFlags( + from: [.command, .shift], + capturedKeyCodes: [UInt16(kVK_RightCommand), UInt16(kVK_Shift)] + ) + + XCTAssertTrue(modifiers.command) + XCTAssertTrue(modifiers.shift) + XCTAssertEqual(modifiers.commandSide, .right) + XCTAssertEqual(modifiers.shiftSide, .left) + } + + @MainActor + func testKeyCaptureModifierFlags_UsesAnyWhenBothSidesWereCaptured() { + let modifiers = KeyCaptureNSView.modifierFlags( + from: [.command], + capturedKeyCodes: [UInt16(kVK_Command), UInt16(kVK_RightCommand)] + ) + + XCTAssertTrue(modifiers.command) + XCTAssertNil(modifiers.commandSide) + } +} + +private final class RecordingInputSimulator: InputSimulatorProtocol, @unchecked Sendable { + enum Event: Equatable { + case pressKey(CGKeyCode, CGEventFlags) + case pressKeyWithModifierFlags(CGKeyCode, ModifierFlags) + case keyDown(CGKeyCode, CGEventFlags) + case keyUp(CGKeyCode) + case holdModifier(CGEventFlags) + case releaseModifier(CGEventFlags) + case holdModifierKey(CGKeyCode) + case releaseModifierKey(CGKeyCode) + } + + private let lock = NSLock() + private var recordedEvents: [Event] = [] + + var events: [Event] { + lock.lock() + defer { lock.unlock() } + return recordedEvents + } + + private func record(_ event: Event) { + lock.lock() + defer { lock.unlock() } + recordedEvents.append(event) + } + + func pressKey(_ keyCode: CGKeyCode, modifiers: CGEventFlags) { + record(.pressKey(keyCode, modifiers)) + } + + func pressKey(_ keyCode: CGKeyCode, modifiers: ModifierFlags) { + record(.pressKeyWithModifierFlags(keyCode, modifiers)) + } + + func keyDown(_ keyCode: CGKeyCode, modifiers: CGEventFlags) { + record(.keyDown(keyCode, modifiers)) + } + + func keyUp(_ keyCode: CGKeyCode) { + record(.keyUp(keyCode)) + } + + func holdModifier(_ modifier: CGEventFlags) { + record(.holdModifier(modifier)) + } + + func releaseModifier(_ modifier: CGEventFlags) { + record(.releaseModifier(modifier)) + } + + func holdModifierKey(_ keyCode: CGKeyCode) { + record(.holdModifierKey(keyCode)) + } + + func releaseModifierKey(_ keyCode: CGKeyCode) { + record(.releaseModifierKey(keyCode)) + } + + func releaseAllModifiers() {} + func isHoldingModifiers(_ modifier: CGEventFlags) -> Bool { false } + func getHeldModifiers() -> CGEventFlags { [] } + func moveMouse(dx: CGFloat, dy: CGFloat) {} + func moveMouseNative(dx: Int, dy: Int) {} + func warpMouseTo(point: CGPoint) {} + var isLeftMouseButtonHeld: Bool { false } + func scroll( + dx: CGFloat, + dy: CGFloat, + phase: CGScrollPhase?, + momentumPhase: CGMomentumScrollPhase?, + isContinuous: Bool, + flags: CGEventFlags + ) {} + func executeMapping(_ mapping: KeyMapping) {} + func startHoldMapping(_ mapping: KeyMapping) {} + func stopHoldMapping(_ mapping: KeyMapping) {} + func typeText(_ text: String, speed: Int, pressEnter: Bool) {} +} diff --git a/XboxControllerMapper/XboxControllerMapperTests/ScriptEngineSecurityTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ScriptEngineSecurityTests.swift index 7b1057c9..3230187d 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ScriptEngineSecurityTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ScriptEngineSecurityTests.swift @@ -18,7 +18,7 @@ private class StubInputSimulator: InputSimulatorProtocol { func moveMouseNative(dx: Int, dy: Int) {} func warpMouseTo(point: CGPoint) {} var isLeftMouseButtonHeld: Bool { false } - func scroll(dx: CGFloat, dy: CGFloat, phase: CGScrollPhase?, momentumPhase: CGMomentumScrollPhase?, isContinuous: Bool, flags: CGEventFlags) {} + func scroll(event: ScrollEvent) {} func executeMapping(_ mapping: KeyMapping) {} func startHoldMapping(_ mapping: KeyMapping) {} func stopHoldMapping(_ mapping: KeyMapping) {} diff --git a/XboxControllerMapper/XboxControllerMapperTests/ScriptExamplesTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ScriptExamplesTests.swift index 7ee43894..0aa586c5 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ScriptExamplesTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ScriptExamplesTests.swift @@ -176,7 +176,7 @@ private class StubScriptInputSimulator: InputSimulatorProtocol { func moveMouseNative(dx: Int, dy: Int) {} func warpMouseTo(point: CGPoint) {} var isLeftMouseButtonHeld: Bool { false } - func scroll(dx: CGFloat, dy: CGFloat, phase: CGScrollPhase?, momentumPhase: CGMomentumScrollPhase?, isContinuous: Bool, flags: CGEventFlags) {} + func scroll(event: ScrollEvent) {} func executeMapping(_ mapping: KeyMapping) {} func startHoldMapping(_ mapping: KeyMapping) {} func stopHoldMapping(_ mapping: KeyMapping) {} diff --git a/XboxControllerMapper/XboxControllerMapperTests/ScriptTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ScriptTests.swift new file mode 100644 index 00000000..b28d5890 --- /dev/null +++ b/XboxControllerMapper/XboxControllerMapperTests/ScriptTests.swift @@ -0,0 +1,149 @@ +import XCTest +@testable import ControllerKeys + +final class ScriptTests: XCTestCase { + + // MARK: - Script Tests + + func testScriptDefaultInitialization() { + let script = Script() + + XCTAssertFalse(script.id.uuidString.isEmpty) + XCTAssertEqual(script.name, "") + XCTAssertEqual(script.source, "") + XCTAssertNil(script.description) + XCTAssertNotNil(script.createdAt) + XCTAssertNotNil(script.modifiedAt) + } + + func testScriptExplicitInitialization() { + let id = UUID() + let createdAt = Date(timeIntervalSince1970: 1000) + let modifiedAt = Date(timeIntervalSince1970: 2000) + + let script = Script( + id: id, + name: "Test Script", + source: "console.log('test');", + description: "A script for testing", + createdAt: createdAt, + modifiedAt: modifiedAt + ) + + XCTAssertEqual(script.id, id) + XCTAssertEqual(script.name, "Test Script") + XCTAssertEqual(script.source, "console.log('test');") + XCTAssertEqual(script.description, "A script for testing") + XCTAssertEqual(script.createdAt, createdAt) + XCTAssertEqual(script.modifiedAt, modifiedAt) + } + + func testScriptCodableRoundTrip() throws { + let originalScript = Script( + name: "My Script", + source: "return true;", + description: "Docs" + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(originalScript) + + let decoder = JSONDecoder() + let decodedScript = try decoder.decode(Script.self, from: data) + + XCTAssertEqual(originalScript, decodedScript) + } + + func testScriptDecodingWithMissingFields() throws { + // Only id is provided, testing defaults in init(from decoder:) + let json = """ + { + "id": "123e4567-e89b-12d3-a456-426614174000" + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + let decodedScript = try decoder.decode(Script.self, from: json) + + XCTAssertEqual(decodedScript.id.uuidString, "123E4567-E89B-12D3-A456-426614174000") + XCTAssertEqual(decodedScript.name, "") + XCTAssertEqual(decodedScript.source, "") + XCTAssertNil(decodedScript.description) + XCTAssertNotNil(decodedScript.createdAt) + XCTAssertNotNil(decodedScript.modifiedAt) + } + + // MARK: - PressType Tests + + func testPressTypeCodable() throws { + let types: [PressType] = [.press, .release, .longHold, .doubleTap] + + let encoder = JSONEncoder() + let data = try encoder.encode(types) + + let decoder = JSONDecoder() + let decodedTypes = try decoder.decode([PressType].self, from: data) + + XCTAssertEqual(types, decodedTypes) + } + + func testPressTypeRawValues() { + XCTAssertEqual(PressType.press.rawValue, "press") + XCTAssertEqual(PressType.release.rawValue, "release") + XCTAssertEqual(PressType.longHold.rawValue, "longHold") + XCTAssertEqual(PressType.doubleTap.rawValue, "doubleTap") + } + + // MARK: - ScriptTrigger Tests + + func testScriptTriggerDefaultInitialization() { + let trigger = ScriptTrigger(button: .a) + + XCTAssertEqual(trigger.button, .a) + XCTAssertEqual(trigger.pressType, .press) + XCTAssertNil(trigger.holdDuration) + XCTAssertNotNil(trigger.timestamp) + } + + func testScriptTriggerExplicitInitialization() { + let timestamp = Date(timeIntervalSince1970: 5000) + let trigger = ScriptTrigger( + button: .leftBumper, + pressType: .longHold, + holdDuration: 2.5, + timestamp: timestamp + ) + + XCTAssertEqual(trigger.button, .leftBumper) + XCTAssertEqual(trigger.pressType, .longHold) + XCTAssertEqual(trigger.holdDuration, 2.5) + XCTAssertEqual(trigger.timestamp, timestamp) + } + + // MARK: - ScriptResult Tests + + func testScriptResultSuccess() { + let resultWithoutHint = ScriptResult.success(hintOverride: nil) + if case let .success(hintOverride) = resultWithoutHint { + XCTAssertNil(hintOverride) + } else { + XCTFail("Expected success") + } + + let resultWithHint = ScriptResult.success(hintOverride: "Press A to jump") + if case let .success(hintOverride) = resultWithHint { + XCTAssertEqual(hintOverride, "Press A to jump") + } else { + XCTFail("Expected success") + } + } + + func testScriptResultError() { + let resultError = ScriptResult.error("Syntax Error") + if case let .error(message) = resultError { + XCTAssertEqual(message, "Syntax Error") + } else { + XCTFail("Expected error") + } + } +} diff --git a/XboxControllerMapper/XboxControllerMapperTests/UniversalControlRelaySecurityTests.swift b/XboxControllerMapper/XboxControllerMapperTests/UniversalControlRelaySecurityTests.swift index b909dcf8..321b45b1 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/UniversalControlRelaySecurityTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/UniversalControlRelaySecurityTests.swift @@ -168,6 +168,45 @@ final class UniversalControlRelaySecurityTests: XCTestCase { XCTAssertFalse(UniversalControlMouseRelay.shared.sendSystemCommand(command)) } + func testSideAwareKeyPressFallsBackToLegacyEncodingWithoutCapability() { + let modifiers = ModifierFlags(command: true, commandSide: .right) + + let line = UniversalControlRelayKeyPressEncoding.line( + keyCode: 36, + modifiers: modifiers, + peerSupportsKP2: false + ) + + XCTAssertEqual(line, "kp 36 \(modifiers.cgEventFlags.rawValue)") + } + + func testSideAwareKeyPressUsesKP2OnlyWithCapability() { + let modifiers = ModifierFlags( + command: true, + option: true, + commandSide: .right, + optionSide: .left + ) + + let line = UniversalControlRelayKeyPressEncoding.line( + keyCode: 36, + modifiers: modifiers, + peerSupportsKP2: true + ) + + XCTAssertEqual(line, "kp2 36 \(modifiers.cgEventFlags.rawValue) 2 1 0 0") + } + + func testRelayCapabilityParserRecognizesKP2() { + XCTAssertEqual(UniversalControlRelayKeyPressEncoding.capabilityLine, "caps kp2") + XCTAssertTrue( + UniversalControlRelayKeyPressEncoding.supportsSideAwareKeyPress(["caps", "kp2"]) + ) + XCTAssertFalse( + UniversalControlRelayKeyPressEncoding.supportsSideAwareKeyPress(["caps", "other"]) + ) + } + func testCodePairingDerivesSameSixDigitCodeAndKey() { let initiator = UniversalControlRelayPairingSession(localPeerID: "macbook", nonce: "aaa") let receiver = UniversalControlRelayPairingSession(localPeerID: "studio", nonce: "bbb") diff --git a/XboxControllerMapper/XboxControllerMapperTests/VariableExpanderTests.swift b/XboxControllerMapper/XboxControllerMapperTests/VariableExpanderTests.swift index fc05ace3..6eef8a60 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/VariableExpanderTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/VariableExpanderTests.swift @@ -118,4 +118,11 @@ final class VariableExpanderTests: XCTestCase { XCTAssertEqual(components.count, 2) XCTAssertEqual(components[0], components[1]) } + + func testPerformanceOfTimeIsoExpansion() { + let input = String(repeating: "{time.iso} ", count: 100) + self.measure { + _ = VariableExpander.expand(input) + } + } } diff --git a/XboxControllerMapper/XboxControllerMapperTests/XboxControllerMapperTests.swift b/XboxControllerMapper/XboxControllerMapperTests/XboxControllerMapperTests.swift index b52d6889..0f08e7a5 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/XboxControllerMapperTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/XboxControllerMapperTests.swift @@ -167,17 +167,10 @@ class MockInputSimulator: InputSimulatorProtocol { lastWarpPoint = point } - func scroll( - dx: CGFloat, - dy: CGFloat, - phase: CGScrollPhase?, - momentumPhase: CGMomentumScrollPhase?, - isContinuous: Bool, - flags: CGEventFlags - ) { + func scroll(event scrollEvent: ScrollEvent) { lock.lock() defer { lock.unlock() } - _events.append(.scroll(dx, dy)) + _events.append(.scroll(scrollEvent.dx, scrollEvent.dy)) } func executeMapping(_ mapping: KeyMapping) { @@ -1394,7 +1387,7 @@ final class XboxControllerMapperTests: XCTestCase { // For now, verify scroll mock exists and events can be recorded await MainActor.run { // The mock should be able to receive scroll events - mockInputSimulator.scroll(dx: 1.0, dy: 2.0, phase: nil, momentumPhase: nil, isContinuous: false, flags: []) + mockInputSimulator.scroll(event: ScrollEvent(dx: 1.0, dy: 2.0, phase: nil, momentumPhase: nil, isContinuous: false, flags: [])) } await MainActor.run { diff --git a/specs/shannon-keybinding-analysis.md b/specs/shannon-keybinding-analysis.md index 35096c7d..917fc8f1 100644 --- a/specs/shannon-keybinding-analysis.md +++ b/specs/shannon-keybinding-analysis.md @@ -271,6 +271,7 @@ struct BindingAnalysis { func analyzeBindings( actionDetailCounts: [String: Int], + profile: Profile, effectiveAlphabetSize: Double = 80 ) -> [BindingAnalysis] { let total = actionDetailCounts.values.reduce(0, +) @@ -280,6 +281,7 @@ func analyzeBindings( let p = Double(count) / Double(total) let optimalCost = -log2(p) / log2(effectiveAlphabetSize) let actualCost = inputCost(for: key) + let hint = BindingAnalysisEngine.descriptionForActionKey(key, profile: profile) return BindingAnalysis( key: key, count: count, @@ -287,7 +289,7 @@ func analyzeBindings( actualCost: actualCost, optimalCost: optimalCost, waste: optimalCost - actualCost, - hint: nil // TODO: look up from profile + hint: hint ) } .sorted { $0.waste < $1.waste } // most negative (most wasted effort) first diff --git a/test_performance.swift b/test_performance.swift new file mode 100644 index 00000000..9f0c912e --- /dev/null +++ b/test_performance.swift @@ -0,0 +1,37 @@ +import Foundation + +// Generate a large array of random words +func generateWords(count: Int) -> [String] { + var words: [String] = [] + let characters = "abcdefghijklmnopqrstuvwxyz" + for _ in 0..