Monthly Archives: April 2018

Helpless Help Menu

I was alerted by Christian Tietze of a pretty bad usability bug in macOS High Sierra. If you are running a Mac app, click the “Help” menu, and then dismiss it, whatever UI element you were focused on in the app loses its focus and does not regain it after dismissing the menu.

The problem is so bad that tabbing, clicking other UI elements, even switching to another app and back does not restore focus on the window’s responders. If the focus was on an NSTextView, such as the editor in MarsEdit, then the blinking cursor continues to animate, but keystrokes are ignored and simply cause the app to beep.

Christian filed a bug, and shared a workaround: set the delegate of the Help menu to your app’s delegate, and listen for the “menuDidClose” delegate method. If it’s the Help menu, restore focus manually.

I generalized this workaround to an approach that should work for whatever window, and whatever responder is currently focused when the Help menu is opened. By saving the window and the responder at “menuWillOpen” time, it can be precisely restored afterwards:

private weak var lastKeyWindow: NSWindow? = nil
private weak var lastResponder: NSResponder? = nil

func menuWillOpen(_ menu: NSMenu) {
   if menu == NSApp.helpMenu {
      if let activeWindow = NSApp.keyWindow {
         self.lastKeyWindow = activeWindow

         if let activeResponder = activeWindow.firstResponder {
            self.lastResponder = activeResponder
         }
      }

      // If the responder is a field editor, then save 
      // the delegate, which is e.g. the NSTextField being edited,
      // rather than the ephemeral NSTextView which will be 
      // removed when editing stops.
      if let textView = lastResponder as? NSTextView,
         textView.isFieldEditor {
         if let realTarget = textView.delegate as? NSResponder {
            lastResponder = realTarget
         }
      }
   }
}

func menuDidClose(_ menu: NSMenu) {
   if menu == NSApp.helpMenu {
      if doWorkaround {
         if let actualKeyWindow = self.lastKeyWindow {
            actualKeyWindow.makeKeyAndOrderFront(nil)
            actualKeyWindow.makeFirstResponder(self.lastResponder)
         }
      }
   }
}

Note that I didn’t clear out the weak var references to the lastKeyWindow and lastResponder. The reason is because part of the bug here involved NSMenu’s menuWillOpen and menuDidClose getting called more often than they probably should be. It’s probably the root issues that is causing the Help menu to excessively take control of the key window. It turns out we are going to get called on menuDidClose twice, so we need to be sure the desired target window and responder are still available the second time around.

As Christian points out, the workaround fixes the worst aspect of the bug: locking up the UI so that typing is ignored, but the focus ring around the target text field doesn’t always get redrawn as expected. My theory is that the focus ring animation is in the process of drawing when the second “menu will open” event is generated, causing the Help menu to reactive itself. The field being reactivate again very shortly after somehow doesn’t trigger the need to redraw the focus ring as you might expect.

I filed an additional bug, Radar #39436005, including a sample project that demonstrates both the bug and the workaround. Until Apple fixes this, Mac developers may want to implement a workaround along the lines demonstrated here. Given the horrible user experience associated with this bug, hopefully Apple will fix it promptly!

Optional Emptiness

Objective-C developers are comfortable with many idioms that fall out of the safety of messaging nil. For example, consider a chunk of Objective-C code that tests the “emptiness” of a UITextField string:

if (myTextField.text.length == 0) {
    // Do something
}

If myTextField.text is nil, what happens? In Objective-C, a message sent to nil will return nil, or zero, depending on the return type of the message. In this case “length” returns an integer, which happens to be zero when text is an empty string, and zero when text is nil. So this code block perfectly expresses “if the text is empty, do something.”

Adapting this code to Swift, you immediately run up against the language’s strict handling of optionals. Because myTextField.text might be nil, it has to be unwrapped before the length method can be called. This leads to less terse code such as:

if myTextField.text?.isEmpty != false {
    // Do something
}

This works! But it’s harder to read, and harder to reason. Similarly to the way the Objective-C version requires deep understanding of that language’s nil-messaging behavior, the Swift version requires deep understanding of optional chaining and comparison of optional and non-optional values. Here’s another example:

if (myTextField.text ?? "").isEmpty {
    // Do something
}

This is much easier to understand: use the non-nil String from myTextField, or else a constant string that is guaranteed to return true for isEmpty. It’s still more cumbersome than the original Objective-C, though.

In my own Swift adventures, I’ve addressed this using Swift’s powerful extension mechanism. It turns out that in Swift, any type that conforms to the “Collection” protocol implements an “isEmpty” method. String is one of these types. So with a small extension, we can add the “isEmpty” method not only to String? but to all optionals that wrap a collection:

extension Optional where Wrapped: Collection {
	public var isEmpty: Bool {
		switch(self) {
		case .none:
			return true
		case .some(let concreteSelf):
			return concreteSelf.isEmpty
		}
	}
}

With this extension in place, our test becomes:

if myTextField.text.isEmpty {
    // Do something
}

Which is both highly readable, behaves correctly when “text” is nil, and doesn’t require any deep language understanding to comprehend.

Thanks to Hwee-Boon Yar for the Objective-C scenario that motivated this post, and to Michel Fortin for putting forward the Swift equivalents cited above. This question came up in the Core Intuition Slack, where interesting discussions like this often take place. Join us!