NSAlert: Why are some buttons not drawn on Big Sur?

Given the code below:

let alert = NSAlert()

alert.alertStyle = .informational
alert.messageText = "Foo"
alert.informativeText = "Bar"

alert.addButton(withTitle: "Foo").keyEquivalent = "\r"
alert.addButton(withTitle: "Bar")
alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}" // esc
alert.runModal()

The resulting alert looks like one would expect:

However, add another button:

let alert = NSAlert()

alert.alertStyle = .informational
alert.messageText = "Foo"
alert.informativeText = "Bar"

alert.addButton(withTitle: "Foo").keyEquivalent = "\r"
alert.addButton(withTitle: "Bar")
alert.addButton(withTitle: "Baz")
alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}" // esc
alert.runModal()

And suddenly you get this:

As you can see, the "Bar" and "Baz" buttons no longer get drawn (except when the mouse hovers over them), even though the "Bar" button draws perfectly normally when it's the only middle button.

I Have Questions:

  1. Is this behavior intentional, or some kind of glitch? The pictures in the macOS HI Guidelines still seem to be showing the Yosemite-era layout, making it difficult to determine what these alerts are supposed to look like.

  2. If this is a glitch, is there a better way to work around it than messing with the NSButton display properties directly?

  3. If this is intentional... why? It's so weird looking...

Replies

Ever get an answer here Charles? Running into the same thing.

I suspect NSAlert is not expecting there to ever be more than 3 buttons.

It looks like NSAlert does a lot of "just in time" relayout that includes bundling all the buttons/etc up in container views depending on the style of the alert. One of the internal methods -[NSAlert buildAlertStyle:title:formattedMessage:first:second:third:oldStyle:] sort of hints at this limitation. 

The documentation for addButtonWithTitle: does suggest it supports more than three buttons, so I would say this is a bug. I suspect the container view holding the buttons is not being allowed to grow tall enough to contain them with the required padding.

Yeah, if it's just straight up expecting no more than 3 buttons, that's definitely a problem, because the old horizontal layout version of NSAlert always handled this just fine...

I was wrong about the assumption that the container view is not being grown tall enough to give the buttons room. The problem is more insidious than that. Here I've arranged for the button container (a stack view) to be tall enough to show all the buttons and then some. You can see from the View Debugger screenshot that the actual visual glitch is rooted in the secondary buttons not having the "button bezel view" that the first button has.

I've confrmed the bezel view is there but is set to hidden. The button and cell in this case are customized by NSAlert and are of class _NSAlertButton and _NSAlertButtonCell. So something is causing it to intentionally hide the bezel for these buttons but I agree it doesn't look good!

I came up with a workaround that seems to achieve the desired outcome. In addition to the buttons not drawing their backgrounds, there's a problematic line that appears after the 2nd button, presumably to set it apart from the cancel button. As a quick fix I just hide it, along with undoing the "draws only while mouse inside" setting that the frameworks have set:

class HackAlert: NSAlert {

	@objc override func layout() {
		super.layout()

		for button in self.buttons {
			button.showsBorderOnlyWhileMouseInside = false
		}

		if let containerView = self.buttons.first?.superview {
			let boxes = containerView.subviews.compactMap { $0 as? NSBox }
			boxes.forEach { $0.isHidden = true }
		}
	}
}

Putting aside the question of whether it's right or wrong to work around this glitch, I do think this looks better, and it seems like a pretty safe way to achieve the desired outcome. It's important to make the tweaks in a subclass override of layout because that is where NSAlert seems to do all of its customizing of the UI before presentation.

I ended up writing a blog post to document this a bit more cohesively: https://indiestack.com/2022/02/hacking-nsalert-button-appearance/