Define and then Hide a New Document Type
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.
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 yourNSPopUpButton
’s target/action mechanism similar to the defaultaccessoryView
ofNSSavePanel
: make itchangeSaveType(_:)
on yourNSDocument
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.
With allowsOtherFileTypes
set, you still get a dialog, but at least you get the option to save as .txt
, which would otherwise be impossible:
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!