diff --git a/Example/Shared/Event/Command/ResponderCommandPreferenceExample.swift b/Example/Shared/Event/Command/ResponderCommandPreferenceExample.swift new file mode 100644 index 000000000..c3f593eb5 --- /dev/null +++ b/Example/Shared/Event/Command/ResponderCommandPreferenceExample.swift @@ -0,0 +1,146 @@ +// +// ResponderCommandPreferenceExample.swift +// Shared + +#if os(macOS) +import AppKit + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +private let resetCommandSelector = NSSelectorFromString("openswiftui_exampleReset:") + +struct ResponderCommandPreferenceExample: View { + @State private var secondPanelEnabled = true + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Responder commands") + .font(.headline) + Text("Use Tab and Shift-Tab to move focus between the panels.") + Text("Arrow keys move the focused panel. Escape resets its position and move count.") + Text("Open Commands to reset the focused panel through the menu.") + + Toggle("Enable second panel commands", isOn: $secondPanelEnabled) + + HStack(alignment: .top, spacing: 16) { + ResponderCommandPanel(title: "First panel", isEnabled: true) + ResponderCommandPanel(title: "Second panel", isEnabled: secondPanelEnabled) + } + + ResponderCommandMenu() + .frame(width: 200, height: 28) + Text("When commands are off, the second panel does not move or reset, and its menu item is disabled.") + } + .padding(24) + .frame(maxWidth: 680) + } +} + +private struct ResponderCommandPanel: View { + let title: String + let isEnabled: Bool + + @State private var x = 0 + @State private var y = 0 + @State private var moveCount = 0 + @State private var resetCount = 0 + @State private var lastCommand = "None" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ResponderCommandPanelLabel(title: title) + Text(isEnabled ? "Commands: On" : "Commands: Off") + Text("Position: (\(x), \(y))") + Text("Moves: \(moveCount)") + Text("Resets: \(resetCount)") + Text("Last command: \(lastCommand)") + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray.opacity(0.1)) + .focusable() + .onMoveCommand(perform: isEnabled ? move : nil) + .onCommand( + #selector(NSResponder.cancelOperation(_:)), + perform: isEnabled ? { reset("Escape") } : nil + ) + .onCommand( + resetCommandSelector, + perform: isEnabled ? { reset("Menu reset") } : nil + ) + } + + private func move(_ direction: MoveCommandDirection) { + switch direction { + case .up: + y += 1 + lastCommand = "Up" + case .down: + y -= 1 + lastCommand = "Down" + case .left: + x -= 1 + lastCommand = "Left" + case .right: + x += 1 + lastCommand = "Right" + @unknown default: + return + } + moveCount += 1 + } + + private func reset(_ source: String) { + x = 0 + y = 0 + moveCount = 0 + resetCount += 1 + lastCommand = source + } +} + +private struct ResponderCommandPanelLabel: View { + @Environment(\.isFocused) private var isFocused + + let title: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(isFocused ? "Focused" : "Not focused") + .foregroundColor(isFocused ? .blue : .gray) + } + } +} + +private struct ResponderCommandMenu: NSViewRepresentable { + func makeNSView(context: Context) -> NSPopUpButton { + let button = NSPopUpButton(frame: .zero, pullsDown: true) + // Keep the command panel focused while the menu is open. + button.refusesFirstResponder = true + let menu = NSMenu() + menu.autoenablesItems = true + menu.addItem(withTitle: "Commands", action: nil, keyEquivalent: "") + let resetItem = menu.addItem( + withTitle: "Reset focused panel", + action: resetCommandSelector, + keyEquivalent: "" + ) + // Let AppKit validate and dispatch through the responder chain. + resetItem.target = nil + button.menu = menu + return button + } + + func updateNSView(_ nsView: NSPopUpButton, context: Context) {} +} + +#Preview { + ResponderCommandPreferenceExample() +} +#endif diff --git a/Sources/COpenSwiftUI/Overlay/AppKit/HostingViewInvocationTarget.h b/Sources/COpenSwiftUI/Overlay/AppKit/HostingViewInvocationTarget.h new file mode 100644 index 000000000..e09b49f70 --- /dev/null +++ b/Sources/COpenSwiftUI/Overlay/AppKit/HostingViewInvocationTarget.h @@ -0,0 +1,42 @@ +// +// HostingViewInvocationTarget.h +// COpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +#ifndef HostingViewInvocationTarget_h +#define HostingViewInvocationTarget_h + +#include "OpenSwiftUIBase.h" + +#if OPENSWIFTUI_TARGET_OS_OSX + +#import + +OPENSWIFTUI_ASSUME_NONNULL_BEGIN + +/// A forwarding target for command selectors registered by NSHostingView. +/// +/// NSHostingView uses this proxy for registered selectors that its superclass +/// does not implement. Each forwarded action must return void and take one +/// object argument. The proxy passes the selector to its handler and ignores +/// the sender argument. +/// +/// The host's handler captures the host weakly and looks up the current command +/// action on each call. The host handles command validation. +OPENSWIFTUI_SWIFT_NAME(_TSHostingViewInvocationTarget) +@interface _OpenSwiftUITSHostingViewInvocationTarget : NSProxy + +/// Creates a proxy that copies and retains the given selector handler. +/// +/// @param handler The block to call with the selector of a forwarded command. +- (instancetype)initWithHandler:(void (^)(SEL))handler; + +@end + +OPENSWIFTUI_ASSUME_NONNULL_END + +#endif /* OPENSWIFTUI_TARGET_OS_OSX */ + +#endif /* HostingViewInvocationTarget_h */ diff --git a/Sources/COpenSwiftUI/Overlay/AppKit/HostingViewInvocationTarget.m b/Sources/COpenSwiftUI/Overlay/AppKit/HostingViewInvocationTarget.m new file mode 100644 index 000000000..fac3c9101 --- /dev/null +++ b/Sources/COpenSwiftUI/Overlay/AppKit/HostingViewInvocationTarget.m @@ -0,0 +1,31 @@ +// +// HostingViewInvocationTarget.m +// COpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +#import "HostingViewInvocationTarget.h" + +#if OPENSWIFTUI_TARGET_OS_OSX + +@implementation _OpenSwiftUITSHostingViewInvocationTarget { + void (^_handler)(SEL); +} + +- (instancetype)initWithHandler:(void (^)(SEL))handler { + _handler = [handler copy]; + return self; +} + +- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector { + return [NSMethodSignature signatureWithObjCTypes:"v@:@"]; +} + +- (void)forwardInvocation:(NSInvocation *)invocation { + _handler(invocation.selector); +} + +@end + +#endif /* OPENSWIFTUI_TARGET_OS_OSX */ diff --git a/Sources/OpenSwiftUI/Event/Command/MoveCommand.swift b/Sources/OpenSwiftUI/Event/Command/MoveCommand.swift new file mode 100644 index 000000000..edcb5240c --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Command/MoveCommand.swift @@ -0,0 +1,45 @@ +// +// MoveCommand.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +public import OpenSwiftUICore + +// MARK: - MoveCommandDirection + +/// Specifies the direction of an arrow key movement. +@available(OpenSwiftUI_v1_0, *) +@available(iOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +public enum MoveCommandDirection: Sendable, Hashable { + case up + case down + case left + case right +} + +// MARK: - View + onMoveCommand + +@available(OpenSwiftUI_v1_0, *) +@available(iOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +extension View { + /// Adds an action to perform in response to a move command, like when the + /// user presses an arrow key on a Mac keyboard, or taps the edge of the + /// Siri Remote when controlling an Apple TV. + nonisolated public func onMoveCommand(perform action: ((MoveCommandDirection) -> Void)?) -> some View { + #if os(macOS) + self + .onCommand(#selector(NSResponder.moveUp(_:)), perform: action.map { action in { action(.up) } }) + .onCommand(#selector(NSResponder.moveDown(_:)), perform: action.map { action in { action(.down) } }) + .onCommand(#selector(NSResponder.moveLeft(_:)), perform: action.map { action in { action(.left) } }) + .onCommand(#selector(NSResponder.moveRight(_:)), perform: action.map { action in { action(.right) } }) + #else + _openSwiftUIUnreachableCode() + #endif + } +} diff --git a/Sources/OpenSwiftUI/Event/Command/ResponderCommandPreference.swift b/Sources/OpenSwiftUI/Event/Command/ResponderCommandPreference.swift new file mode 100644 index 000000000..2fde21f4e --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Command/ResponderCommandPreference.swift @@ -0,0 +1,113 @@ +// +// ResponderCommandPreference.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 1D7D458D08B5AD38421D04B7C887FEF0 (SwiftUI) + +#if canImport(ObjectiveC) +@_spi(Private) +public import OpenSwiftUICore + +// MARK: - InteropResponder + +class InteropResponder: NSObject { + @objc func cut(_ sender: Any?) { + _openSwiftUIEmptyStub() + } + + @objc func copy(_ sender: Any?) { + _openSwiftUIEmptyStub() + } + + @objc func paste(_ sender: Any?) { + _openSwiftUIEmptyStub() + } + + @objc func delete(_ sender: Any?) { + _openSwiftUIEmptyStub() + } +} + +// MARK: - CommandAction + +protocol CommandAction { + func perform() + var isValid: Bool { get } +} + +#if os(macOS) +// MARK: - StandardCommandAction + +private struct StandardCommandAction: CommandAction { + var action: (() -> Void)? + + func perform() { + action?() + } + + var isValid: Bool { + action != nil + } +} +#endif + +// MARK: - ResponderCommandsKey + +struct ResponderCommandsKey: HostPreferenceKey { + static let defaultValue: [Selector: any CommandAction] = [:] + + static func reduce(value: inout [Selector: any CommandAction], nextValue: () -> [Selector: any CommandAction]) { + value.merge(nextValue()) { first, _ in first } + } +} + +// MARK: - View + onCommand + +@available(OpenSwiftUI_v1_0, *) +@available(iOS, unavailable) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +extension View { + /// Adds an action to perform in response to the given selector. + /// + /// This view or one of the views it contains must be in focus in order + /// for the action to trigger. Other actions for the same command + /// on views *closer* to the view in focus take priority, potentially + /// overriding this action. + /// + /// - Parameters: + /// - selector: The selector to register for `action`. + /// - action: The action to perform. If `action` is `nil`, `command` + /// keeps its association with this view but doesn't trigger. + /// + /// - Returns: A view that triggers `action` when the `command` occurs. + nonisolated public func onCommand(_ selector: Selector, perform action: (() -> Void)?) -> some View { + #if os(macOS) + modifier(OnCommandModifier(command: selector, action: StandardCommandAction(action: action))) + #else + _openSwiftUIUnreachableCode() + #endif + } +} + +// MARK: - OnCommandModifier + +private struct OnCommandModifier: ViewModifier { + var command: Selector + var action: any CommandAction + @FocusState private var isFocused: Bool + + func body(content: Content) -> some View { + content + .focused($isFocused) + .transformPreference(ResponderCommandsKey.self) { commands in + if isFocused, commands[command] == nil { + commands[command] = action + } + } + } +} +#endif diff --git a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift b/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift index cc354c182..571bb0c39 100644 --- a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift +++ b/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift @@ -32,7 +32,7 @@ import COpenSwiftUI @available(tvOS, unavailable) @available(watchOS, unavailable) @available(visionOS, unavailable) -open class NSHostingView: NSView, XcodeViewDebugDataProvider where Content: View { +open class NSHostingView: NSView, NSUserInterfaceValidations, XcodeViewDebugDataProvider where Content: View { /// The options for how the hosting view creates and updates constraints /// based on the size of its OpenSwiftUI content. /// @@ -126,10 +126,23 @@ open class NSHostingView: NSView, XcodeViewDebugDataProvider where Cont // TODO: Forward changes to AccessibilityViewGraph when that feature is available. var currentAccessibilityFocusStore = AccessibilityFocusStore() + private var interpretingKeyEvent = false + + private var handledKeyEvent = false + private var keyEventTracker = KeyEvent.Tracker() private var isOfferingKeyEventToFirstResponder = false + private var currentResponderCommands = ResponderCommandsKey.defaultValue + + private lazy var _forwardingTarget = _TSHostingViewInvocationTarget { [weak self] selector in + if let action = self?.responderAction(for: selector) { + self?.handledKeyEvent = true + action.perform() + } + } + private var eventsInProgress: Set = [] private var eventsPendingReset: Set = [] @@ -306,6 +319,7 @@ open class NSHostingView: NSView, XcodeViewDebugDataProvider where Cont wantsLayer = true // TODO renderer.host = self + viewGraph.addPreference(ResponderCommandsKey.self) eventBindingManager.host = self if GestureRecognizerBasedEvents.isEnabled { eventBindingManager.delegate = eventBridge @@ -574,11 +588,22 @@ open class NSHostingView: NSView, XcodeViewDebugDataProvider where Cont } open override func keyDown(with event: NSEvent) { + func interpretKeyEventsForResponderCommands() -> Bool { + handledKeyEvent = false + if !currentResponderCommands.isEmpty { + interpretingKeyEvent = true + interpretKeyEvents([event]) + interpretingKeyEvent = false + } + return handledKeyEvent + } + guard !isOfferingKeyEventToFirstResponder, lastInterceptedKeyEvent === event else { - // TODO: Interpret responder commands before forwarding. - super.keyDown(with: event) + if !interpretKeyEventsForResponderCommands() { + super.keyDown(with: event) + } return } guard !sendKeyEvent(event, phase: .began) else { @@ -588,8 +613,7 @@ open class NSHostingView: NSView, XcodeViewDebugDataProvider where Cont isOfferingKeyEventToFirstResponder = true firstResponder.keyDown(with: event) isOfferingKeyEventToFirstResponder = false - } else { - // TODO: Interpret responder commands before forwarding. + } else if !interpretKeyEventsForResponderCommands() { super.keyDown(with: event) } } @@ -611,6 +635,43 @@ open class NSHostingView: NSView, XcodeViewDebugDataProvider where Cont } } + private func responderAction(for selector: Selector?) -> (any CommandAction)? { + guard let selector else { return nil } + return currentResponderCommands[selector] + } + + public func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool { + responderAction(for: item.action)?.isValid ?? false + } + + open override func insertText(_ insertString: Any) { + if !interpretingKeyEvent { + super.insertText(insertString) + } + } + + open override func doCommand(by selector: Selector) { + if interpretingKeyEvent { + if let action = responderAction(for: selector) { + handledKeyEvent = true + action.perform() + } + } else { + super.doCommand(by: selector) + } + } + + open override func responds(to selector: Selector!) -> Bool { + super.responds(to: selector) || responderAction(for: selector) != nil + } + + open override func forwardingTarget(for selector: Selector!) -> Any? { + if responderAction(for: selector) != nil, !super.responds(to: selector) { + return _forwardingTarget + } + return super.forwardingTarget(for: selector) + } + private func sendKeyEvent(_ event: NSEvent, phase: EventPhase) -> Bool { guard let keyEvent = event.resolve(phase: phase) else { return false @@ -1318,6 +1379,7 @@ extension NSHostingView: ViewRendererHost { @_spi(ForOpenSwiftUIOnly) public func preferencesDidChange() { let preferences = viewGraph.preferenceValues() + currentResponderCommands = preferences[ResponderCommandsKey.self].value focusBridge.preferencesDidChange(preferences) // TODO: Notify the remaining preference bridges. delegate?.hostingView(self, didChangePreferences: preferences)