TextField shortcuts in NSWindow created programmatically vs SwiftUI

108 views Asked by At

I'm having a issue with a simple SwiftUI view containing a TextField.

If the View is inside a SwiftUI App > Scene > WindowGroup, the TextField in the view behave as expected and I can use shortcuts inside the TextField like Command+a to select all the text in it or copy/past.

If the View is inside a NSHostingView set as the contentView of a NSWindow created programmatically, my TextField doesn't accept shortcuts using the Command key. I can still type text or use shortcuts with CTRL but with Command the bell ring and nothing happens.

Here is the code for the working version:

import Foundation
import SwiftUI

struct MyView: View {
    @State var text = ""
    
    var body: some View {
        TextField("My textfield", text: $text).padding()
    }
}

@main
struct TestCoreDataSwiftApp: App {
    var body: some Scene {
        WindowGroup {
            MyView()
        }
    }
}

Here is the code for the not working version:

import Foundation
import Cocoa
import SwiftUI

let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate

_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

struct MyView: View {
    @State var text = ""
    
    var body: some View {
        TextField("My textfield", text: $text).padding()
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {
    private var window: NSWindow?
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        window = NSWindow()
        window?.contentView = NSHostingView(rootView: MyView())
        window?.makeKeyAndOrderFront(self)
        window?.makeFirstResponder(nil)
    }
}

[EDIT]

I did more tests.

If everything is created with Interface Builder (drag and drop a NSTextField inside a NSView), it also works as expected.

If we create a NSView programmatically and add a NSTextField, here again it doesn't work:

    // Not working code
    func applicationDidFinishLaunching(_ aNotification: Notification) 
    {
        NSApplication.shared.activate(ignoringOtherApps: true)
        window = NSWindow(contentRect: NSRect(x: 400, y: 400, width: 400, height: 100), styleMask: [.miniaturizable, .closable, .resizable, .titled], backing: .buffered, defer: false)
        window.center()
        window.title = "Title"
        window.contentView = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 300))
        let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 60))
        window.contentView?.addSubview(textField)
        window.makeKeyAndOrderFront(nil)
    }

I suspect it's something related to the focus or the firstResponder but I'm unable to make it work. Any help is welcome. Thanks!

2

There are 2 answers

1
Vincent On BEST ANSWER

In fact, those shortcuts are related to the application menu (NSMenu). They don't exist by default without it.
So, if the application doesn't have the Edit menu where all this shortcuts are defined, they are simply not available in the TextField.

In the various cases where it works, it's because both the default IB project and the Swift UI project add this menu by default.

It's not a matter of firstResponder in this case.

In order to make this work, the solutions are:

  1. Add the Edit menu in the application with the shortcuts (doesn't work if the menu is hidden).
  2. Manage shortcuts programmatically as suggested by VonC
class NSHostingViewShortcuts<Content>: NSHostingView<Content> where Content : View {
    override func performKeyEquivalent(with event: NSEvent) -> Bool {
        if event.modifierFlags.contains(.command) {
            let shortcuts = [
                "a": #selector(NSStandardKeyBindingResponding.selectAll(_:)),
                "x": #selector(NSText.cut(_:)),
                "c": #selector(NSText.copy(_:)),
                "v": #selector(NSText.paste(_:))
            ]
            if event.characters != nil && shortcuts[event.characters!] != nil {
                NSApp.sendAction(shortcuts[event.characters!]!, to: nil, from: nil)
                return true
            }
        }
        
        return super.performKeyEquivalent(with: event)
    }
}
1
VonC On

In the working version, SwiftUI handles the keyboard shortcuts natively.
However, in the non-working version, NSWindow is not configured to pass on keyboard shortcuts like Command+a to the SwiftUI view hosted inside the NSHostingView.

That means you need to make sure your NSWindow is properly forwarding these events to the NSHostingView.
A possible approach would be to subclass NSHostingView and override the performKeyEquivalent(_:) method to manually handle these shortcuts.

import Foundation
import Cocoa
import SwiftUI

let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate

_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

struct MyView: View {
    @State var text = ""
    
    var body: some View {
        TextField("My textfield", text: $text).padding()
    }
}

class CustomNSHostingView<Content>: NSHostingView<Content> where Content : View {
    override func performKeyEquivalent(with event: NSEvent) -> Bool {
        // Handle Command key shortcuts here
        // Example: Command + a
        if event.modifierFlags.contains(.command) {
            if event.characters == "a" {
                // Do something for Command + a
                return true
            }
            // Add other command key shortcuts as needed
        }

        return super.performKeyEquivalent(with: event)
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {
    private var window: NSWindow?
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        window = NSWindow()
        window?.contentView = CustomNSHostingView(rootView: MyView())
        window?.makeKeyAndOrderFront(self)
        window?.makeFirstResponder(nil)
    }
}

You would need to expand this to include other shortcuts like copy and paste.


From Vincent's answer, the suggestion to subclass NSHostingView and override the performKeyEquivalent(with:) method was helpful: it allows for direct control over how keyboard events are handled when the standard Edit menu is not available or when the application's structure does not support these shortcuts.

Overriding performKeyEquivalent(with:) provided a way to intercept keyboard events and implement custom behavior, which is essential for handling shortcuts like Command+a.

I missed the presence or absence of an Edit menu, which impacts the availability of these shortcuts. And I missed the specific mapping of these actions to their respective selectors, which is crucial for mimicking the behavior of the Edit menu.
Using NSApp.sendAction to send the actions to the responder chain is a key detail for implementing these shortcuts.

If I combines my initial approach and the OP's, I can still subclass NSHostingView, override performKeyEquivalent(with:), and map keyboard shortcuts to the corresponding NSMenu actions using NSApp.sendAction.

import Foundation
import Cocoa
import SwiftUI

let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate

_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

struct MyView: View {
    @State var text = ""
    
    var body: some View {
        TextField("My textfield", text: $text).padding()
    }
}

class CustomNSHostingView<Content>: NSHostingView<Content> where Content : View {
    override func performKeyEquivalent(with event: NSEvent) -> Bool {
        if event.modifierFlags.contains(.command), let characters = event.characters {
            let shortcuts: [String: Selector] = [
                "a": #selector(NSStandardKeyBindingResponding.selectAll(_:)),
                "x": #selector(NSText.cut(_:)),
                "c": #selector(NSText.copy(_:)),
                "v": #selector(NSText.paste(_:))
            ]

            if let action = shortcuts[characters] {
                NSApp.sendAction(action, to: nil, from: nil)
                return true
            }
        }

        return super.performKeyEquivalent(with: event)
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {
    private var window: NSWindow?
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        window = NSWindow()
        window?.contentView = CustomNSHostingView(rootView: MyView())
        window?.makeKeyAndOrderFront(self)
        window?.makeFirstResponder(nil)
    }
}

By subclassing NSHostingView and handling the key equivalents within it, that allows for easy extension or modification of shortcut handling, making it more adaptable to different contexts or additional shortcuts.