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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions Example/Shared/Event/Command/ResponderCommandPreferenceExample.swift
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>

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 */
Original file line number Diff line number Diff line change
@@ -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 */
45 changes: 45 additions & 0 deletions Sources/OpenSwiftUI/Event/Command/MoveCommand.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
113 changes: 113 additions & 0 deletions Sources/OpenSwiftUI/Event/Command/ResponderCommandPreference.swift
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading