Define and then Hide a New Document Type

null

All previous installments in this series were written some time during the past 6 weeks or so. I couldn’t spend a lot of consecutive time on eitherthe coding problem or the posts, so it became a pain to keep up with all the details and experiments – I hope I have nevertheless told a somewhat intelligible story so far.

But 6 weeks ago, my original post to document what was going on went in a totally different direction, and I didn’t publish it so far. The things that I did publish, I discovered much, much later in the process, and in a different order. It makes a lot more sense to tell the story in the order that I told it, though.

With everything we’ve learned so far, the stuff I struggled with at first make a lot more sense now. Here’s follows a revised description of what I initially tried but ultimately found useless once I figured out the rest.

Recap of the final quick fix

In the last episode, I started with a NSSavePanel accessoryView that didn’t auto-select a file format, and finished with a quick post-fix to make a selection for my particular case.

This is the code, for reference: I hook into the prepareSavePanel callback of NSDocument to select the first item of the file type NSPopUpButton if nothing is selected at all:

asd

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
    }
}

Note I had to defer triggering the usual side effects of a user-based selection. I could leave these lines out and would not perceive any change in behavior, but since I don’t know what usually happens behind the scenes in an NSDocument, it’d be inconsistent to change the selection without triggering the callbacks, I think.

My original, different approach

The following things I tried way, way before I realized that my calls to revert(toContentsOf:ofType:) were the reason things broke down.

Originally, I came to the conclusion that the plain text format is baked into the system and that macOS refuses to let me register the .txt file extension for my own Document Type. You couldn’t have both, it seemed.

So I didn’t want to fight the framework. I didn’t want to continue tryin to treat essentially 3 document types (plain text, Markdown, and CSV) as 2 (Markdown and CSV). It apparently didn’t work well so far.

Consequently, I added a third document type, “Plain Text”, to cover the public.plain-text UTI, and then handled it separately. In all the places where my NSDocument subclass had to know if it supported writing as CSV or Markdown at all, adding Plain Text as another trigger for the Markdown path wasn’t that much of a problem.

Add the Plain Text UTI document type

First, add another document type to the Info.plist to register the app to recognize the plain text format:

<key>CFBundleDocumentTypes</key>
<array>
    <dict>
        <key>LSItemContentTypes</key>
        <array>
            <string>public.text</string>
            <string>public.plain-text</string>
        </array>
        <key>CFBundleTypeName</key>
        <string>Plain Text</string>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>LSHandlerRank</key>
        <string>Alternate</string>
        <key>LSIsAppleDefaultForType</key>
        <false/>
        <key>LSTypeIsPackage</key>
        <integer>0</integer>
        <key>NSDocumentClass</key>
        <string>$(PRODUCT_MODULE_NAME).MyAmazingDocument</string>
    </dict>
    <!-- ... all the other document types here ... -->
</array>

Handle the new document type in code

I then added another file type in code, where all known UTIs and file extensions are defined as decision helpers for the NSDocument.

public enum DocumentType {
    case plaintext // <- new!
    case markdown
    case csv

    public var canonicalTypeName: String {
        switch self {
        case .plaintext: return "public.plain-text"
        case .markdown: return "net.daringfireball.markdown"
        case .csv: return "public.csv"
        }
    }
}

The existing configuration of Markdown documents had to be changed, too, so it wouldn’t overlap with plain text anymore. With my document configuration types, that resulted in code like this:

enum DefaultDocumentTypeConfigurations {
    static let plaintext = DocumentTypeConfiguration(
        documentType: .plaintext,
        fileType: DocumentTypeConfiguration.FileType(
            reading: [
                "public.plain-text",
                "public.text"
            ], writing: [
                "public.plain-text",
                "public.text"
            ]),
        extensions: [
            "txt"
        ])

    static let markdown = DocumentTypeConfiguration(
        documentType: .markdown,
        fileType: DocumentTypeConfiguration.FileType(
            reading: [
                "net.multimarkdown.text",
                "net.daringfireball.markdown"
            ], writing: [
                "net.multimarkdown.text",
                "net.daringfireball.markdown",
                "pro.writer.markdown"
            ]),
        extensions: [
            "md", "mdown", "markdown", "mmd", "text", "txt"
        ])
}

With this code in place, I now could distinguish files depending on their NSDocument.fileType and UTIs. Some resolve as DefaultDocumentTypeConfigurations.plaintext, some as .markdown. In practice, both cases were handled the same, but keeping them apart helped to get rid of the original error where the file type and thus the file extension would change.

It is a clean separation of what the system considers to be separate types.

This originally made me very happy already, because it looks like this is a way to properly model the reality in code. The system thinks that plain text files are special because they have a well-known UTI? Fine, then we’ll treat them in a special way in code, too.

Unintended consequences when adding new document types

As far as I know, there’s no way to tell the NSSavePanel to not include some file formats in its format picker accessoryView when NSDocument.shouldRunSavePanelWithAccessoryView is true (the default). It simply includes all matching document types, and that’s it.

After adding the Plain Text document type to the Info.plist, NSSavePanel displays the new document type automatically.

This poses a problem, because with all the changes above users would now be able to select Markdown, CSV, or Plain Text from the file format picker. There’s no such thing as a non-Markdown plain text table file as far as TableFlip is concerned. — I only want to read in .txt files as Markdown documents, remember?

Remove an offending file format from the NSPopUpButton

So I want to support plain text as a document type and use the .txt file extension. But I don’t want the free NSSavePanel.accessoryView to list the document type separately. It should be treated as Markdown. There’s no additional configuration to skip formats in the format picker. What to do?

I could whip up my own accessoryView, but we’re not doing that here. If you want to go that route, you may get the effect of switching file formats for free when you set up your NSPopUpButton’s target/action mechanism similar to the default accessoryView of NSSavePanel: make it changeSaveType(_:) on your NSDocument and see what happens!

The document type’s name (CFBundleTypeName) is set to “Plain Text” in my Info.plist. When I don’t set it, the popup button will show the UTI, "public.plain-text". So since I set it in the Info.plist, I know with certainty what the popup button’s item title is – it’s not provided by the system. It’s under my control.

Armed with the knowledge of the existing popup button item title and the default target/action setup, it’s simple to find the NSPopUpButton in the accessoryView and remove one of its items:

/// Makes the selector `changeSaveType(_:)` available to Swift.
/// It's used as the popup button's action, but is private API.
@objc protocol NSSavePanelFileTypeDelegate: class {
    func changeSaveType(_ sender: Any?)
}

/// Helper to find a view in a view hierarchy.
fileprivate func findSubview<V: NSView>(
        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(view: subview, where: predicate) {
            return result
        }
    }
    return nil
}

class TableDocument: NSDocument {
    // ...
    override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
        guard super.prepareSavePanel(savePanel) else { return false }
        
        savePanel.isExtensionHidden = false
        savePanel.allowsOtherFileTypes = true

        // Find the item's index for plain text in the `accessoryView`.
        guard let accessoryView = savePanel.accessoryView,
            let button: NSPopUpButton = findSubview(
                view: accessoryView,
                where: { $0.action == #selector(NSSavePanelFileTypeDelegate.changeSaveType(_:)) }) 
            let plainTextIndex = button.itemArray
                .firstIndex(where: { $0.title == "Plain Text" })
            else { return true }
    
        button.removeItem(at: plainTextIndex)
        button.selectItem(at: 0)

        return true
    }
    // ...
}

This is all we need to remove the superfluous NSPopUpButton item.

Enable users to pick file types on their own

You may have noticed that I also customized the save panel a bit more. I changed allowsOtherFileTypes because the .txt extension is a “recognized extension that’s not in the list of allowed types”, as the API docs put it. I apparently cannot bypass this by exporting the Markdown document type with .txt as an allowed extension. The recognition of plain text is just too strong with this computer.

By default, you cannot willy-nilly use a different, but known, file extension.

With allowsOtherFileTypes set, you still get a dialog, but at least you get the option to save as .txt, which would otherwise be impossible:

With allowsOtherFileTypes the user gets a warning, but can pick any extension.

Wrapping up

According to my tests, this hack works fine. You can remove any of the file format items from a save panel without breaking anything.

The worst thing I had feared was that somewhere the index values are used privately, and when you remove an item from the beginning, everything else gets confused. But that apparently is not the case. The private changeSaveType(_:) method must do something else to figure out which file format the user selected – maybe depending on the item’s title. I don’t know.

This leaves us with another hack. You get the file format selector for free, and then you drill it open and change it. I still kinda prefer this over creating your own accessoryView because at least it looks just right.

Is this hack any better than the one I used in the previous episode? There, I modified the default format picker’s initial selection so it wouldn’t show with an empty selection. I’m not sure.

  • The empty selection from the other post looks like a bug I introduced that I then steered around; you’re not supposed to not select anything there, so I clearly must’ve configured something wrong. (And yes, I kinda did when I changed the configuration to get to that point!) This feels kind of brittle. But if macOS ever changed to select another item by default whenever the selection would be empty, my auto-selection quick fix wouldn’t kick in.
  • Here, I rely on the same mechanism to automatically generate the accessoryView but don’t like what AppKit produces. The result is 100% correct based on the info I pass in. There is no bug, it’s expected behavior. I feel that this is a better foundation than building on a buggy appearance. – My building-upon the foundation means I remove an item from the format picker, though. That’s more invasive than fixing an empty selection.

It’s the plague or cholera. Either have a brittle foundation and use a less invasive fix, or have a solid foundation and outright remove parts of the default UI.

You may choose your own path to hell. I picked mine and went with the selection fix-up.

Maybe one day I’ll revisit this and scratch everything, then recognize Plain Text as a proper document type and implement my own accessoryView to pick file formats, even though I despise how that is supposed to work.

Thanks for tagging along, and godspeed to you and your document-based application development!