In Part 1 of my multipart text view adventures, I wrote about an NSTextView
memory management bug with attachments that could result in "zombie movies". Zombie movies are bad, m'kay. You don't want to see zombie movies. I explained how to make the zombies disappear, and I thought I killed the zombies, but later I realized that zombies are dead already, so killing them is fruitless. Who knew? It turns out that the zombies were merely in hiding, waiting to attack again. The only true way to avoid zombie movies is to never make them in the first place.
Enough with the metaphors, though. Let's get on with the similes. Like, now. When NSTextAttachment
switched from QuickTime to AVKit in macOS 10.13, it brought more problems than just zombie movies. AV stands for (among other things) audiovisual, which means that AVKit handles audio as well as video. Try dragging an mp3
file into a TextEdit Rich Text document. It works! Err, for some definition of works. An attachment is created for the mp3
file. However, the attachment is just a strange dark rectangle in the document that doesn't play audio or even have audio controls.
Here's another problem that I discovered by total chance:
.mp3
or .mov
file$ open "$TMPDIR"com.apple.TextEdit
In step 2, a copy of the audio or video file is created in the temporary directory. This is less than ideal, for a number of reasons. If you delete the document without quitting TextEdit, the temporary file does get deleted automatically, but if you quit TextEdit, the temporary file is left over. This is much less than ideal, and it happens with any app that uses NSTextView
.
I explained in Part 1 that NSTextAttachment
uses AVPlayerView
when attachmentCell
returns nil
. You can see the implementation of -[NSTextAttachment attachmentCell]
and much more with a Terminal command:
$ otool -tV /System/Library/PrivateFrameworks/UIFoundation.framework/UIFoundation
I won't show the disassembly here, but an analysis of it reveals that the method usesTextAttachmentView
controls whether NSTextAttachment
uses AVKit or QuickTime for non-image attachments such as audio and video. If usesTextAttachmentView
returns YES
then attachmentCell
returns nil
, but if usesTextAttachmentView
returns NO
then -[NSTextAttachment _attachmentCell]
gets called to create an attachment cell, using QuickTime when appropriate. Thus, you can subclass NSTextAttachment
and override -[NSTextAttachment usesTextAttachmentView]
to return NO
in order to prevent your NSTextView
from using AVKit. When QuickTime is used instead of AVKit, temporary files no longer get created at all in $TMPDIR
, which suggests that this behavior is the fault of AVKit and not the fault of NSTextView
itself.
Although this workaround for AVKit problems seems attractive prima facie, there are several major pitfalls if you follow that path. First, usesTextAttachmentView
is a private method, not public API, so it would not be allowed in the Mac App Store. Second, we have no idea how long the QuickTime fallback will continue to work. It could disappear entirely in macOS 10.15 Nieve Mojave.
I personally took a different approach, which is to dispense entirely with inline previews of audio and video files. Aside from the problems already discussed, inline previews from both AVKit and QuickTime have their own special contextual menus, preventing you from using the standard NSTextView
contextual menu on text attachments. The contextual menu was important for my purposes, so I decided to replace inline previews with a file icon. You already see a file icon for attachments of file formats that NSTextAttachment
cannot preview, so my approach does not deviate far from expectations. A simple NSTextAttachment
subclass will suffice, and does not require any private methods:
@interface JJTextAttachment : NSTextAttachment
@end
@implementation JJTextAttachment {
NSTextAttachmentCell *_JJTextAttachmentCell;
}
-(id<NSTextAttachmentCell>)attachmentCell {
if (_JJTextAttachmentCell == nil) {
NSString *fileType = [self fileType];
if (fileType == nil)
fileType = @"public.data";
NSImage *image = [[NSWorkspace sharedWorkspace] iconForFileType:fileType];
_JJTextAttachmentCell = [[NSTextAttachmentCell alloc] initImageCell:image];
[_JJTextAttachmentCell setAttachment:self];
}
return _JJTextAttachmentCell;
}
@end
The trick is to replace NSTextAttachment
with your subclass in the NSTextView
. That's not a problem in text that you control, but it's a problem in an editable text view, where the user can paste in file attachments. What I did was set the NSTextStorageDelegate
of the NSTextView
and implement a delegate method. Here's some code to do that:
-(void)textStorage:(NSTextStorage *)textStorage willProcessEditing:(NSTextStorageEditActions)editedMask range:(NSRange)editedRange changeInLength:(NSInteger)delta {
NSUInteger length = editedRange.length;
if (length == 0)
return;
if ((editedMask & NSTextStorageEditedCharacters) != NSTextStorageEditedCharacters)
return;
[textStorage enumerateAttribute:NSAttachmentAttributeName inRange:editedRange options:0 usingBlock:^(id value, NSRange range, BOOL *stop) {
if (value == nil) // Text, not file
return;
if (![value isKindOfClass:[NSTextAttachment class]])
return;
if ([value isKindOfClass:[JJTextAttachment class]])
return;
if ([(NSTextAttachment *)value attachmentCell] != nil)
return;
NSFileWrapper *fileWrapper = [(NSTextAttachment *)value fileWrapper];
if (fileWrapper == nil) {
NSLog(@"No file wrapper for text attachment: %@", value);
return;
}
JJTextAttachment *attachment = [[JJTextAttachment alloc] initWithFileWrapper:fileWrapper];
NSAttributedString *attachmentString = [NSAttributedString attributedStringWithAttachment:attachment];
if (attachmentString == nil) {
NSLog(@"Cannot create attachment string: %@", fileWrapper);
return;
}
[textStorage replaceCharactersInRange:range withAttributedString:attachmentString];
}];
}
This gives you an NSTextView
that handles audio and video attachments simply and gracefully. We've reached the end of this blog post, which I'll handle simply and gracefully by saying farewell until Part 3.