The World's Most Comprehensive Guide to Implementing Programmatic Creation of Tabs in a Single NSWindowController That You Shouldn't Adhere To
Table of Contents
-
Experimental observations
- Windows without a tab bar will not always have a tab group
- The selected tab’s window will be key and main window when you set it programmatically
- Manually selecting a tab will make it key, but not main window – at first
- Tabbed windows also have a list of all tabbed windows!
- The original window will not have a special status
- Closing the root window will break the naive addition of tabs
- Switching tabs actually changes the visible window frame on screen
- Fix the window controller’s notion of “its window” to always know the active tab
- If you close most tabs that had key status, the window frame will reset when you add a new one
- Conclusion
This is a follow-up to “Programmatically Add Tabs to NSWindows without NSDocument” from January this year. There, I was creating NSWindow
instances from storyboards and re-used a single window controller to manage them. The point of that post was to solve the problem that newly created tabs will themselves not respond to the “+” button – or any menu action, for that matter, because they fell out of the responder chain at first.
This is a continuation of that idea. Please note that these are all experimental observations.
Do not use this in production. This just documents the behavior of tabs and windows. Read my production-ready guide instead.
Again, I do not recommend the approach of switching out a NSWindowController
’s main window
reference when switching tabs for the following reasons:
- As Dave DeLong pointed out, it feels wrong: you wouldn’t switch out a
NSViewController.view
but instead create a new view/controller combo. Same should go for windows. - Here, the windows are empty; fully functional window controls will likely break in practice.
- You run into all kinds of weird behavor, as documented below.
Even though I do not recomment this approach, I understand people are looking for this and I can recall why I tried to do it this way myself. After all, the default tab creation selector is newWindowForTab(_:)
. It can be implemented anywhere in the responder chain between the window (which displays the tab bar) and the AppDelegate
(which is probably the last responder you have control over). I presume that most apps don’t have many NSResponder
objects between AppDelegate
and NSWindowController
s, so implementing the callback in your window controllers is an idea that comes natural.
Keep in mind that implementing the callback in the NSWindowController
does not mean you need to implement everything related to tab creation there! You can inform a delegate that manages multiple controllers about the event. I’ll write about a better approach in an upcoming post.
Experimental observations
Still interested in the experiment?
For the scope of this exploration into tabs, we will work with this implementation of newWindowForTab(_:)
, which is called when you hit the “+” button in a window’s tab bar. I put it into a window controller.
- It shares a single window controller among all tabs.
- It treats
NSWindowController.window
as the “root window” and adds tabs to it. We’ll talk about that label in a minute. - It activates the newly added tab immediately.
override func newWindowForTab(_ sender: Any?) {
guard let rootWindow = self.window else {
preconditionFailure("Expected window to be loaded")
}
let newWindowController = self.storyboard!.instantiateInitialController() as! WindowController
let newWindow = newWindowController.window!
// Add as a new tab to the right of the original window
mainKeyWindow.addTabbedWindow(newWindow, ordered: .above)
newWindow.delegate = self // Set delegate first to notify about key window change
newWindow.makeKeyAndOrderFront(self)
// `newWindowController` is not referenced and will be deallocated at the end of
// this method, so use 1 shared controller to keep the window in the responder chain.
newWindow.windowController = self
}
Windows without a tab bar will not always have a tab group
To maybe nobody’s surprise, this is the effect of NSWindow.TabbingMode
on a sole NSWindow
:
.disallowed
: both the window’stabbedWindows
array and thetabGroup
are nil..preferred
: thetabbedWindows
array contains 1 element, the window itself, and thetabGroup
is non-nil..automatic
: the window’stabbedWindows
array is nil if the tab bar is hidden, buttabGroup
is non-nil. If you show the tab bar, it’s the same as starting with.preferred
.
When a window is tab-able, it has a NSWindowTabGroup
. When it shows tabs, its tabbedWindows
array will show the visible tabs.
But even if tabbedWindows
is nil, the tabGroup.windows
will contain 1 element, the window itself!
So we can think of tabbedWindows
as the visible tabs, or a representation of the view state, while tabGroup
contains the “tab model”.
The concrete type of AppKit’s default tabGroup
instance if NSWindowStackController
, by the way, which appears to be a private type.
When your root window shows with the tabbing mode set to .automatic
, tabbedWindows
will still be nil during windowDidLoad
. Only later is the array not-nil.
This is how I find out which window is contained in the collection, by the way: by setting a breakpoint and inspecting the memory addresses in the debugger output.
(lldb) po self.window!
<NSWindow: 0x600003e0c400>
(lldb) po self.window!.tabbedWindows
▿ Optional<Array<NSWindow>>
▿ some : 1 elements
- 0 : <NSWindow: 0x600003e0c400>
The selected tab’s window will be key and main window when you set it programmatically
Let’s call the window controllers window
property the “root window”, because we start with that one and insert tabs into it.
When you open tabs next to the root window, i.e. to the right of it, you will notice that the root window will remain key and main window.
Root window <NSWindow: 0x600003e04300> Window #1 has tabs:
- <NSWindow: 0x600003e04300> Window #1 isKey = true , isMain = true
- <NSWindow: 0x600003e05800> Window #5 isKey = false , isMain = false
- <NSWindow: 0x600003e05000> Window #4 isKey = false , isMain = false
- <NSWindow: 0x600003e08600> Window #3 isKey = false , isMain = false
- <NSWindow: 0x600003e20200> Window #2 isKey = false , isMain = false
When you send makeKeyAndOrderFront(nil)
to the new tab so it becomes active after showing, see what happens if you log the window hierarchy afterwards:
Root window <NSWindow: 0x600003e0c100> Window #1 has tabs:
- <NSWindow: 0x600003e0c100> Window #1 isKey = false , isMain = false
- <NSWindow: 0x600003e04700> Window #5 isKey = true , isMain = true
- <NSWindow: 0x600003e02e00> Window #4 isKey = false , isMain = false
- <NSWindow: 0x600003e06c00> Window #3 isKey = false , isMain = false
- <NSWindow: 0x600003e02d00> Window #2 isKey = false , isMain = false
Note that only sending the new tab’s window the makeKey()
message will not activate the tab. You need makeKeyAndOrderFront
!
Manually selecting a tab will make it key, but not main window – at first
Also note that manually selecting a tab will make it key window, but it will not make it the main window of the app. Here’s the log output from windowDidBecomeKey(_:)
after I selected a tab manually:
Root window <NSWindow: 0x600003e08500> Window #1 has tabs:
- <NSWindow: 0x600003e08500> Window #1 isKey = false , isMain = true
- <NSWindow: 0x600003e0eb00> Window #5 isKey = false , isMain = false
- <NSWindow: 0x600003e0ea00> Window #4 isKey = true , isMain = false
- <NSWindow: 0x600003e00b00> Window #3 isKey = false , isMain = false
- <NSWindow: 0x600003e0e900> Window #2 isKey = false , isMain = false
The main window state will change, too, eventually, but only after the windowDidBecomeKey(_:)
callback has exited. If you’re curious, add a key-value subscription and see for yourself:
NSApp!.observe(\.mainWindow, options: [.new, .old]) { [weak self] (app, change) in
print("main window \(change.oldValue) -> \(change.newValue)")
}
This will show the change after windowDidBecomeKey(_:)
exited.
When you Cmd-Tab in and out of the app again after a new tab was seleted, the windowDidBecomeKey(_:)
callback is invoked again. Then, when tabbing back into the app, you’ll the that the selected tab is both main and key window. So don’t be fooled by the initial output like I was for quite some time.
Tabbed windows also have a list of all tabbed windows!
This surprised me, but is somewhat consistent with the active tab becoming key and main window (eventually).
When you add a tabbed window, all windows, including the root window, will have the same tabbedWindows
content:
Root window <NSWindow: 0x600003e20000> Window #2 has tabs:
- <NSWindow: 0x600003e08600> Window #1 isKey = false , isMain = true , and has tabs:
- <NSWindow: 0x600003e08600> Window #1 isKey = false , isMain = true
- <NSWindow: 0x600003e20000> Window #2 isKey = true , isMain = false
- <NSWindow: 0x600003e20000> Window #2 isKey = true , isMain = false , and has tabs:
- <NSWindow: 0x600003e08600> Window #1 isKey = false , isMain = true
- <NSWindow: 0x600003e20000> Window #2 isKey = true , isMain = false
Because I sometimes get asked how I figure this stuff out, I put the following print statements into newWindowForTab(_:)
after adding the tab:
func inspectWindowHierarchy() {
let rootWindow = windowController.window!
guard let tabbedWindows = rootWindow.tabbedWindows else {
print("Root window", rootWindow, "does not have tabs!")
return
}
print("Root window", rootWindow, "has tabs:")
rootWindow.tabbedWindows?.forEach { window in
print("- ", window, "isKey =", window.isKeyWindow, ", isMain =", window.isMainWindow, ", and has tabs:")
window.tabbedWindows?.forEach { subWindow In
print(" - ", subWindow, "isKey =", subWindow.isKeyWindow, ", isMain =", subWindow.isMainWindow)
}
}
}
The original window will not have a special status
When you make another tab active via makeKeyAndOrderFront
, it becomes key and main window. That’s just how a non-tabbed window you activated and brought to front would behave.
But I found this confusing at first, especially before I found out all the rest above, and I still am not sure what this really means. Who is responsible for drawing the frame and tab bar?
Are the windows switched out from underneath each other to make it appear that the frame on screen stays the same? (This would explain the odd behavior of the frame resetting, see below.)
Originally I expected the root window to give up key status, but not main status. I expected it to remain “special” in some way, but it turns out I couldn’t find any specialty, as you will see.
Closing the root window will break the naive addition of tabs
Say you have 10 tabs opened and close the tab for the root window. Then tabs 1...9
will be visible. But once you try to add a new tab by calling rootWindow.addTabbedWindow(...)
again, you’ll find out that a new window frame will open that includes the rootWindow
’s tab and the new one. So closing the root window apparently affects how addTabbedWindow
behaves.
Before jumping to conclusions, let’s have a look at what happens to the tabbesWindows
arrays.
To inspect this, make the window controller the new windows’ delegate if you haven’t already:
newWindow.delegate = self
Note: Setting newWindow.windowController = self
does not, in fact, change the delegate. You have to do this in an extra step, just like your Xib or Storyboard comes with a delegate
wiring from the get-go.
Then I print the window hierarchy again once the key window changes, which happens automatically after I close the root window’s tab and another tab gets focused:
func windowDidBecomeKey(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
guard window != self.window else { return }
inspectWindowHierarchy()
}
The output:
Root window <NSWindow: 0x600003e0c000> does not have tabs!
Bummer. So closing the root window effectively removes our access to all of the remaining tabs on screen. There still is a window frame drawn with the remaining tabs, but the window controller has no means to get there. Note that the root window’s tabbedWindows
is nil, and its tabGroup.windows
contains only a reference to itself.
Before you ask: I also had a look at the root window’s childWindows
. The visible window isn’t listed there, either. The windows have no connection I could find between each other. As far as AppKit is concerned, the closed window is gone, detached from the remaining visible tabs, and their controller cannot get to them.
Switching tabs actually changes the visible window frame on screen
Next, start fresh and add 2 tabs to the application. I select the 3rd tab, stop execution in Xcode, open the View Debugger, and inspect the NSWindow
that is part of the window controller scene. Its object address is not the address of the root window, it’s the address of the window corresponding to the current tab.
You may have noticed I said “visible window” or “window frame” a lot in this post. It’s because naming things gets confusing.
From a user’s perspective, switching tabs changes the visible contents of the window. It does not exchange the window, as far as a user perceives any change at all. The window, with its titlebar and its close, maximize, and minimize buttons, stays the same. But in the world of AppKit, that’s not the case. The window on screen is replaced with the window of the selected tab, and the tab bar just shows a different active selection.
The thing visible on screen I call the “window frame”, to reference the drawn artifact. It seemingly stays constant. But “the window”, as in NSWindow
, is different under the hood.
How can you figure out which window is the one used to draw the frame onto your screen?
- Find the active tab. As long as the
rootWindow
is visible, you can userootWindow.tabbedWindows.first(where: { $0.isKeyWindow })
to get the key window, i.e. the active tab. - Find the active app’s window. Once the
rootWindow
is closed, and itstabbedWindows
reference is nilled-out, your only chance is to use the application’s global window list:NSApp.windows.first(where: { $0.isKeyWindow })
. (In applications with e.g. a preferences window or when you show the About panel, you’ll need to use a different filter predicate to exclude all the windows that are not the tab-based ones.)
Note than when you open the standard About panel, NSApp.windows.first(where: { $0.isKeyWindow })
will produce the About panel because it has key window status. But the About panel will not be the main window; it’s an auxiliary window only. Use NSApp.windows.first(where: { $0.isMainWindow })
to get to the window with the tabs in it.
Fix the window controller’s notion of “its window” to always know the active tab
I do not like that the root window is still referenced by the window controller although it’s been closed.
The notion of a “root window” was introduced by myself to distinguish between “the window visible from the start”, and “window for new tabs”. This made me believe the root window retained a special status, but nothing indicates this is the case.
It doesn’t make sense to cling to it. What I thought to be the root window really is nothing special at all. Its similar to all other windows in the tab group:
- all windows contain a list of
tabbedWindows
, including the one we started with, and - closing any window removes it from
tabbedWindows
and also nils out its own list of tabs.
If the root window does not have any special status as far as AppKit is concerned, then the window controller shouldn’t treat it in a special way and stop holding on to it once it’s closed.
Let’s reflect this matter by updating the strong NSWindowController.window
reference to point to the currently active tab.
Make sure the window controller still is the NSWindowDelegate
of all windows for this to work:
class WindowController: NSWindowController, NSWindowDelegate {
// ...
func windowDidBecomeKey(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
// Replace the reference to the main key window
guard window != self.window else { return }
self.window = window
}
}
Now you can close the original window’s tab and add tabs to the root window of your window controller. It will add tabs next to the currently active tab in all cases, and another frame with the previously closed window will not pop up again!
If you close most tabs that had key status, the window frame will reset when you add a new one
This is hard to pin down accurately, but it’s related to some status that tabs have when you activate them most of the time, but not always. One way to reproduce, moving the window frame before you add a new tab to see if the window frame moves at all:
- Close cither the first root window’s tab, or
- whichever tab gets key and main window status after the root tab closes, or
- whichever tab gets key and main window status after the previous initial tab closes.
There’s a quirk if you call makeKeyAndOrderFront(nil)
when showing a new tab. If you do, closing the tab will often result in the window frame snapping back; if you don’t, you can get away with closing tabs just fine.
To isolate the factors, I deactivated making the new tab key. Then have a look at the windows’s frame
values after creating 2 tabs:
Root window <NSWindow: 0x600003e00800> Window #1 has tabs:
- <NSWindow: 0x600003e00800> Window #1 isKey = true , isMain = true at (420.0, 343.0, 840.0, 513.0)
- <NSWindow: 0x600003e02100> Window #3 isKey = false , isMain = false at (196.0, 240.0, 480.0, 292.0)
- <NSWindow: 0x600003e0a300> Window #2 isKey = false , isMain = false at (196.0, 240.0, 480.0, 292.0)
- Only the original window is visually centered on screen, which is the post-app-launch default,
- only the original window has a fitting height.
(I experimented with different window sizes and positions. The new tab’s windows always start with (196.0, 240.0, 480.0, 292.0)
before they become visible for the first time.)
What happens when you switch tabs?
Root window <NSWindow: 0x600003e00800> Window #1 has tabs:
- <NSWindow: 0x600003e00800> Window #1 isKey = false , isMain = true at (420.0, 343.0, 840.0, 513.0)
- <NSWindow: 0x600003e02100> Window #3 isKey = true , isMain = false at (420.0, 343.0, 840.0, 513.0)
- <NSWindow: 0x600003e0a300> Window #2 isKey = false , isMain = false at (196.0, 240.0, 480.0, 292.0)
Okay, so when a window’s tab activates, its frame is adjusted. Move around, switch again, move again, switch again, and you end up with this:
Root window <NSWindow: 0x600003e05600> Window #2 has tabs:
- <NSWindow: 0x600003e04400> Window #1 isKey = false , isMain = true at (718.0, 294.0, 840.0, 638.0)
- <NSWindow: 0x600003e01b00> Window #3 isKey = false , isMain = false at (-27.0, 225.0, 840.0, 638.0)
- <NSWindow: 0x600003e05600> Window #2 isKey = true , isMain = false at (718.0, 294.0, 840.0, 638.0)
This means that after switching from #1 to #2, #2 did update its frame to the current position of the original main window on screen. Let’s switch back to #1 and close the tab so #3 becomes active. Does it snap back now? No, the window frame on screen stays puts.
Interestingly, the frame origin and size after snapping back do not correspond to previous values. The window frame resizes when it snaps back for the first time, getting a bit taller. Note that in my demo app, the windows are empty and the contentView
does not produce any meaningful intrinsicContentSize
. The problem could look a bit different if you have a content view and/or an autosave name for the window frame.
Whatever the factors, a quick fix for this is to force-restore the frame position:
func windowDidBecomeKey(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
guard window != self.window else { return }
// When changing whichever window is owned by the controller,
// restore the previous key window's frame to prevent snapping
// back to the center.
let oldFrame = self.window?.frame
self.window = window
if let oldFrame = oldFrame {
window.setFrame(oldFrame, display: true)
}
}
Conclusion
As I said in the introduction, I do not recommend building on top of this. The behavior I witnessed is too weird already. I don’t know into which kinds of trouble I’ll run if I used this in a complex application like The Archive, where the user interface needs to stay responsive. I don’t want to mess up window autosaving information, including the widths of split view parts. Since the window frame snaps back until you force it not to, I suspect that something like this might happen all too easily.
T’was a fun experiment, though. I learned a lot about tab groups and the real meaning of key and main window status, and that tabbing just replaces the on-screen NSWindow
instance. It’s always a bit enlightening to see how my perception as a user differs from the actual implementation. In this case, AppKit replaces the on-screen window completely and does not simply switch out content views.
I hope that having published this post will help humankind in the long run. My naive implementation of tabbing started with this approach, because NSWindowController
is such a convenient place to respond to newWindowForTab(_:)
! Don’t be like me, be smart and implement a better window controller management in your AppDelegate, if that works for you, or in a custom service object which we’ll explore in the next post.