macoscocoafirst-respondernsviewcontrollernswindowcontroller

How to close window (NSWindowController) by hitting the ESC key?


Issue

I would like the user being able to close a window by hitting the ESC key but I can't get it to work in this specific case, hitting ESC triggers an error sound (the "no you can't do that" macOS bloop) and nothing happens.

Context

I'm making a subclass of NSWindowController which itself creates an instance of a subclass of NSViewController and sets it in a view. Both controllers have their own xib file.

NSWindowController:

final class MyWindowController: NSWindowController, NSWindowDelegate {

    @IBOutlet weak var targetView: MainView!

    let myVC: MyViewController!
    var params: SomeParams!

    override func windowDidLoad() {
        super.windowDidLoad()
        myVC = MyViewController(someParams: params)
        myVC.view.setFrameSize(targetView.frame.size)
        myVC.view.setBoundsSize(targetView.bounds.size)
        targetView.addSubview(myVC.view)
    }

    override var windowNibName: String! {
        return "MyWindowController"
    }

    convenience init(someParams params: SomeType) {
        self.init(window: nil)
        self.params = params
    }

}

NSViewController:

final class MyViewController: NSViewController {

    convenience init(someParams params: SomeType) {
        // do stuff with the params
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        // configure stuff for the window
    }

}

What I've tried

I suppose that my issue is that the MyWindowController NSWindow is the .initialFirstResponder when I would want the content of the targetView (an NSTableView) to be the first responder - this way I could use keyDown, I guess, and send the close command to the window from there. This doesn't seem optimal, though.

I've tried forcing the view controller views into being the first responder by using window?.makeFirstResponder(theView) in the windowDidLoad of MyWindowController but nothing ever changes.

I've also tried adding this to MyWindowController:

override func cancelOperation(_ sender: Any?) {
    print("yeah, let's close!")
}

But this only works if the user clicks first on the background of the window then hits ESC, and it still emits the error sound anyway. Which is actually what made me think that the issue was about the first responder being on the window.

Question

How would you achieve that? Of course, I know that the user can already close the window with CMD+W, but I'd really like to sort out this issue nonetheless.

Note that the code example is in Swift but I can also accept explanations using Objective-C.


Solution

  • The documentation of cancelOperation explains how cancelOperation should work:

    This method is bound to the Escape and Command-. (period) keys. The key window first searches the view hierarchy for a view whose key equivalent is Escape or Command-., whichever was entered. If none of these views handles the key equivalent, the window sends a default action message of cancelOperation: to the first responder and from there the message travels up the responder chain.

    If no responder in the responder chain implements cancelOperation:, the key window searches the view hierarchy for a view whose key equivalent is Escape (note that this may be redundant if the original key equivalent was Escape). If no such responder is found, then a cancel: action message is sent to the first responder in the responder chain that implements it.

    NSResponder declares but does not implement this method.

    NSWindow implements cancelOperation: and the next responder, the window controller, isn't checked for an implementation of cancelOperation:. The cancel: message does arrive at the window controller. Implementing

    - (void)cancel:(id)sender
    {
        NSLog(@"cancel");
    }
    

    will work. The cancel: message isn't inherited from a superclass so autocompletion doesn't suggest it.