The World's Most Comprehensive Guide to Implementing Programmatic Creation of Tabs in a Single NSWindowController That You Shouldn't Adhere To

null
Table of Contents
  1. Experimental observations
    1. Windows without a tab bar will not always have a tab group
    2. The selected tab’s window will be key and main window when you set it programmatically
    3. Manually selecting a tab will make it key, but not main window – at first
    4. Tabbed windows also have a list of all tabbed windows!
    5. The original window will not have a special status
    6. Closing the root window will break the naive addition of tabs
    7. Switching tabs actually changes the visible window frame on screen
    8. Fix the window controller’s notion of “its window” to always know the active tab
    9. If you close most tabs that had key status, the window frame will reset when you add a new one
  2. 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:

  1. 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.
  2. Here, the windows are empty; fully functional window controls will likely break in practice.
  3. 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 NSWindowControllers, 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’s tabbedWindows array and the tabGroup are nil.
  • .preferred: the tabbedWindows array contains 1 element, the window itself, and the tabGroup is non-nil.
  • .automatic: the window’s tabbedWindows array is nil if the tab bar is hidden, but tabGroup 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.

What happens when you open new tabs and close the root window's tab

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 use rootWindow.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 its tabbedWindows 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.

Adding a new tab after closing the last important one results in the window snapping back to center.

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.