Programmatically Select a NSSavePanel File Format to Fix Empty Selections

null

In my quest to vanquish bugs from TableFlip, we intially observed that file extensions would attempt to change after an external file change. It turnes out that this was my fault because I effectively always passed "net.daringfireball.markdown", even when the document type originally was "public.plain-text". Passing the wrong file type to NSDocument.revert(toContentsOf:ofType:) was causing the issue. Trying to use the optional NSDocument.fileType property first, and then providing a fallback, solved the file extension problem.

It caused another quirk, though. One that I remember from the early days of developing TableFlip: when you would get a “Save As” dialog, the default NSSavePanel.accessoryView displays a file format picker, which I like, but it now doesn’t auto-select the Markdown format there in this case. If the document’s fileType is "public.plain-text", the format picker has an empty selection:

Save-As dialog for a public.plain-text document is in an invalid state.

The save operation still works fine. The picker just looks broken. That’s not acceptable, of course.

It appears that this only happens when a .txt file is opened and then “Save As” is used. The auto-selection works perfectly fine when a CSV file is being saved-as, or duplicated, or when a brand new document is saved to disk.

The good news is that the picker shows all the supported document types, nothing more and nothing less. I point this out because back in the day I had a 3rd picker item show with public.plain-text for some configurations.

Sorting all this document saving out is oddly frustrating, even though the documentation appears to be pretty straight-forward. Even after all these years, I still don’t feel confident in my changes right off the bat and have to experiment with tons of things to get a feel for how the components behave. It’s not bad, but it’s weird, and annoying. If you get in a similar situation, know that you’re not alone with this.

We can observe how the save panel is configured in NSDocument.prepareSavePanel:

override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
    guard super.prepareSavePanel(savePanel) else { return false }

    Swift.print(savePanel.allowsOtherFileTypes)
    // => false
    Swift.print(savePanel.allowedFileTypes)
    // => ["net.daringfireball.markdown"]
    Swift.print(self.writableTypes(for: .saveAsOperation))
    // => ["net.multimarkdown.text", "net.daringfireball.markdown", "public.csv"]

    Swift.print(self.fileType)
    // => "public.plain-text"
    Swift.print(self.autosavingFileType)
    // => "public.plain-text"
    
    return true
}

Why Swift.print? Because just print would call NSDocument.print, invoking the actual document printing dialog.

With these default settings, you cannot pick just any file extension like .fantasy – the app will outright forbid that choice. Here’s the override point to change that.

It’s interesting to see that allowedFileTypes contains just one element, even though writableTypes(for:) returns all possible formats for the current document (CSV is included because the test file only contains 1 table; with 2+ tables, only Markdown would be listed). Also worth noting is that the first element of the document type UTI array is used (the LSItemContentTypes setting), and not the first element in the list of supported file types. So the Info.plist settings clearly win here. When I reorder the elements there, the new first item of the Markdown document type will be picked.

There’s no easy way to access the NSSavePanel’s format picker. The accessoryView is just any old NSView, and you have to traverse the view hierarchy to find the NSPopUpButton and change its selection.

As a quick fix to put the app into a consistent state, as far as user interactions are concerned, I want to select the “Markdown” document type when nothing is selected and the document itself is in fact a Markdown-compatible document. (Which, incidentally, is always the case. If you have an app with different document type layouts, your mileage may vary.)

Since only .txt files do not auto-select something, I can focus on this one case.

Finding the NSPopUpButton in a NSSavePanel’s accessoryView and selecting an item programmatically

Through debugging, I found out that the NSPopUpButton’s action is changeSaveType(_:). That is private API of NSDocument. This means that Swift cannot find the appropriate symbol when you write #selector(changeSaveType(_:)). Since this action–target-mechanism is from Objective-C-land and uses dynamic dispatch anyway, you can just invent a protocol with a method of a similar signature and use that. It doesn’t matter that the actual implementation doesn’t correspond to the formal protocol definition, because the NSObject’s informal protocol conformance is all what counts – i.e. the fact that -respondsToSelector: returns true when tested for a method with this name.

To find the correct NSPopUpButton, you can filter through all the subviews and select a NSPopUpButton where the action property equals #selector(YourProtocolName.changeSaveType(_:)). That will totally work.

Be aware that this is private API that could have changed throughout the eons. It could change with the next macOS system upgrade for all we know. It’s not exactly Apple’s day-to-day practice to rename methods like this in AppKit, but you should be aware for the potential issues you can run into.

Since this is private API, and since I am certain that the save panel only has one format picker NSPopupButton by default anyway, we can get by without the action == #selector(YourProtocolName.changeSaveType(_:)) predicate.

For completions sake, I’ll include it here, but please exercise caution if you find yourself in a similar situation and consider using this exact code!

// The formalized protocol of the informal private API
@objc protocol NSSavePanelFileTypeDelegate: class {
    func changeSaveType(_ sender: Any?)
}

extension MyAmazingDocument: NSDocument {
    override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
        guard super.prepareSavePanel(savePanel) else { return false }
        
        // Adjust the save panel configuration:
        savePanel.isExtensionHidden = false
        savePanel.allowsOtherFileTypes = true
        
        // Find the format picker:
        if let accessoryView = savePanel.accessoryView,
            let button: NSPopUpButton = findSubview(
                in: accessoryView,
                where: { $0.action == #selector(NSSavePanelFileTypeDelegate.changeSaveType(_:)) }) {

            // Fix the empty selection
            if button.selectedItem == nil {
                button.selectItem(withTitle: "Markdown")
            }
        }

        return true
    }
}

/// Find a subview inside `view`'s hierarchy that is of type `V` and satisfies `predicate`.
fileprivate func findSubview<V: NSView>(
        in view: NSView, 
        where predicate: (V) -> Bool) -> V? {
    if let view = view as? V, predicate(view) { return view }
    for subview in view.subviews {
        if let result = findSubview(in: subview, where: predicate) {
            return result
        }
    }
    return nil
}

But beware!

This does change the NSPopUpButton’s selected item, but it does not execute any side effect of a user-originated selection. I display the file extension in this panel. So switching from Markdown to CSV will change the file extension from .md to .csv in the file name text field at the top.

With this kind of programmatic selection change, nothing like that happens. I tried it with the CSV format. The picker selection changes, but the file extension remains at its original state and displays .md. That’s an inconsistency you’d have to look out for.

Apparently, calling NSApp.sendAction(button.action!, to: button.target, from: button) is performing some of the side effects: passing the wrong sender into this call produces a runtime error. So something happens, but not even calling this as another band aid will solve the inconsistency.

I don’t want no more inconsistencies, even if I cannot observe any problems, so here’s a more thorough or “correct” quick fix. It’s still a hack, but it’s a better hack.

extension MyAmazingDocument: NSDocument {
    override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
        guard super.prepareSavePanel(savePanel) else { return false }
        
        // Adjust the save panel configuration:
        savePanel.isExtensionHidden = false
        savePanel.allowsOtherFileTypes = true
        
        // Find and fix the format picker:
        if let accessoryView = savePanel.accessoryView,
            let button: NSPopUpButton = findSubview(
                in: accessoryView,
                where: { $0.action == #selector(NSSavePanelFileTypeDelegate.changeSaveType(_:)) }) {

            if button.selectedItem == nil,
                // We can safely assume this is non-nil due to 
                // the `where`-clause above.
                let action = button.action {
                
                button.selectItem(at: 0)
                
                // Fake a user interaction here to execute the
                // cascade of NSSavePanel side effects
                DispatchQueue.main.async {
                    NSApp.sendAction(action, to: button.target, from: button)
                }
            }
        }

        return true
    }
}

Yes. DispatchQueue.main.async, aka enqueueing the fake user interaction onto the next run loop iteration (or later), does the trick.

I changed button.selectItem(withTitle: "Markdown") to button.selectItem(at: 0) because with these changes in place, it doesn’t matter if the programmatic selection matches the original suggestion anymore. The save panel will be in a consistent state. Might as well not hard-code the item title in there and just select the first item. (Which, in TableFlip’s case, is the Markdown format. The first element, I’d like to think, is the preferred one anyway.)

This works. Case closed, for now. Up next is a rundown of a different way I tackled this whole problem until I came full-circle and ended up preferring this.