NSTextField usesSingleLineMode Stops Working When You Implement NSTextViewDelegate Methods

Today I learned why my NSTextField permits pasting of newline characters even though I set usesSingleLineMode properly. It's because I made it conform to NSTextViewDelegate to cache changes.

When you edit text inside of an NSTextField, you actually type inside a field editor of the window. That's a shared NSTextView instance. Most of the hard work of an NSTextField is done by its cell, which is an NSTextCell. NSTextCells implement at least the delegate method NSTextViewDelegate.textView(_:shouldChangeTextIn:replacementText:) – and when you set usesSingleLineMode, this is actually set for the cell, not the view itself. You can use textView(_:shouldChangeTextIn:replacementText:) to sanitize input text, and I suspect that's where the usesSingleLineMode implementation happens. If your NSTextField subclass implements this method, the NSTextCell implementation isn't called. And since that one isn't public (it was called "implicit protocol conformance" back in the day), you cannot delegate up in Swift because the compiler knows it isn't there.

NSTextFields register as the delegate of their field editor and seemingly forward some delegate calls to their cells. That's good to know and can be exploited for all kinds of things – you don't have to mess around with the field editor's delegate on your own at all. You always know it's the text field being edited.

Since I cannot delegate back to NSTextCell and just decorate what the framework's doing anyway, I have to find a different solution.

I was using the delegate method to record the text before and after the change so I could cache both and compute a diff later. Since there is no "will change" notification for NSText, NSTextView, NSTextField, or NSControl, that sounded like a good idea. But without the ability to merely decorate the default behavior, I'm looking for alternatives. Here's what I think one could do:

The Objective-C adapter I wrote checks if the receiver responds to the method first and looks like this:

@implementation DelegatableTextField

- (BOOL)del_textView:(NSTextView *)textView shouldChangeTextInRange:(NSRange)affectedCharRange replacementString:(NSString *)replacementString {
    if (![self.cell respondsToSelector:@selector(textView:shouldChangeTextInRange:replacementString:)]) {
        NSAssert(false, @"NSTextField's cell should responds to NSTextViewDelegate functions");
        return true;
    }
    return [((id<NSTextViewDelegate>)self.cell) textView:textView
                                 shouldChangeTextInRange:affectedCharRange
                                       replacementString:replacementString];
}

@end

To make this available in a Swift framework target, you need to include the header file in the framework's public header, sadly. There's no project internal bridging header in that case. But I can live with that prefix. That's how you handled implicit protocol conformance.

It's used from the Swift class as you'd expect:

func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool {

    // `replacementString` is `nil` for attribute changes
    guard let replacementString = replacementString else { 
        return super.del_textView(textView, shouldChangeTextIn: affectedCharRange, replacementString: replacementString)
    }

    let oldText = textView.string
    cacheTextChange(original: oldText, 
        replacement: replacementString,
        affectedRange: affectedCharRange)

    return super.del_textView(textView, shouldChangeTextIn: affectedCharRange, replacementString: replacementString)
}

Works. I'm happy. Still, it's an ugly solution.

"But couldn't you do the same from your Swift delegate method?" – Sadly, no. In Swift's type system, you cannot case the NSCell to NSTextViewDelegate; in Objective-C, protocol conformance casts won't fail, only message sending will.

So this is how I do it. You may do it in a similar way. Please tell me if you find a better solution. Or even better, create a pull request with a fix!

What was actually going on in usesSingleLineMode, after all?

While I'm at it, let's log what's happening. To my surprise, the sanitization is more like a post-hoc fix:

NSTextDidChange notification: "a\nb"
NSTextDidChange notification: "a b"
NSTextField change: "a b"

So when I paste a text with newline characters, the text is simply replaced. Notice how the NSTextField delegate won't know about the initial paste.

The stack trace tells a more complete story when I break in the NSTextDidChange notification handler:

#0  0x0000000100004007 in closure #1 in AppDelegate.applicationDidFinishLaunching(_:)
#1  0x0000000100004372 in thunk for @escaping @callee_guaranteed (@in Notification) -> () ()
#2  0x00007fff545dc640 in -[__NSObserver _doit:] ()
#3  0x00007fff524b461c in __CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ ()
#4  0x00007fff524b44ea in _CFXRegistrationPost ()
#5  0x00007fff524b4221 in ___CFXNotificationPost_block_invoke ()
#6  0x00007fff52472d72 in -[_CFXNotificationRegistrar find:object:observer:enumerator:] ()
#7  0x00007fff52471e03 in _CFXNotificationPost ()
#8  0x00007fff5459b8c7 in -[NSNotificationCenter postNotificationName:object:userInfo:] ()
#9  0x00007fff4fbaf761 in -[NSTextView(NSSharing) didChangeText] ()
#10 0x00007fff4fbb00e6 in -[NSCell textDidChange:] ()
#11 0x00007fff4fbafe64 in -[NSTextField textDidChange:] ()
#12 0x00007fff524b461c in __CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ ()
#13 0x00007fff524b44ea in _CFXRegistrationPost ()
#14 0x00007fff524b4221 in ___CFXNotificationPost_block_invoke ()
#15 0x00007fff52472d72 in -[_CFXNotificationRegistrar find:object:observer:enumerator:] ()
#16 0x00007fff52471e03 in _CFXNotificationPost ()
#17 0x00007fff5459b8c7 in -[NSNotificationCenter postNotificationName:object:userInfo:] ()
#18 0x00007fff4fbaf761 in -[NSTextView(NSSharing) didChangeText] ()

At (18) you see what happens when you paste. (17)–(12) dispatch the notification; (11)–(9) shows that the sanitization produces another round of changes – that eventually reach my subscriber at (0).

That's because NSTextView.didChangeText triggers the notification, NSTextField responds to its field editor's textDidChange, hands this down to the NSTextCell, then that cell changes the text in the field editor after the fact again, and that triggers a new notification. That was unexpected.

You cannot fake that behavior from within the delegate method:

// Warning, does not work:
func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool {

    if let replacementString = replacementString,
        replacementString.contains("\n") {
            textView.insertText(replacementString.replacingOccurrences(of: "\n", with: " "), replacementRange: affectedCharRange)
    } 

    return true
}

The actual replacement is happening from within the original NSCell.textDidChange, not the delegate method, and I have no clue why that isn't happening when I don't forward the call up to NSTextField from my own delegate method implementation. Maybe it's a private state toggle that's triggered in the delegate method when you paste \n and which is then processed later in textDidChange. In any case, NSTextDidChange is triggered by the field editor in the regular fashion, only the fixup won't happen if you implement textView(_:shouldChangeTextIn:replacementString:) yourself.

Browse the blog archive