cocoanstextfieldnscolorpanelnscolorwell

selectable NSTextField and NSColorPanel – how to break their undesired interplay?


In a seemingly trivial setup, I encounter an undesired interplay between a selectable NSTextField and an NSColorPanel that I cannot get rid of and that drives me nuts.

Here’s the setup: Within one window, I have a selectable Multi-Line Label (de facto an NSTextField) and an NSColorWell.

The Color Well allows the user to color geometric objects in the GUI; it has nothing to do with text whatsoever. Of course, clicking on the color well activates it, i.e. brings up the shared NSColorPanel and connects the color well to it.

The Text Field is completely independent from the colored objects in the GUI and presents data to the user. It is read-only, i.e. not editable. Since the data is organized in columns, I use tabs for text formatting and the setAttributedStringValue: method of NSTextField to display the data.

At first glimpse, everything works as you would expect in a such a trivial setup.

But here comes the rub: I want the user to be able to copy the data in the text field to process it elsewhere. Therefore, the NSTextField has to be selectable. And setting it to be selectable is where the problems start:

When the user clicks on the selectable text field to select the text, the window’s field editor takes over, and as a consequence, all the tab settings of the attributed text are lost and the text gets mingled. The usual way to prevent this is to set the allowsEditingTextAttributes property of the NSTextField to YES. If I do this, the tab formatting is preserved when the user selects the text. But now the NSColorPanel (if visible) unintentionally also switches to the text color (always black), and if the color well is active (connected to the NSColorPanel), it will remain active, thereby changing the color of all geometric GUI objects to black. Ouch!

I have found no way to set the selectable and allowsEditingTextAttributes properties of NSTextField to YES but still prevent it from communicating with the NSColorPanel.

The obvious alternative route would be to preserve the tab formatting for selected text even with allowsEditingTextAttributes set to NO (which would disconnect the color panel from the text field, as desired). But I’ve had no success with this approach either, although I do not really understand why:

My idea was to set the required tabs as the defaultParagraphStyle of the field editor of the text field. So, I set up a customized field editor:

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
    {
        NSArray *myTabs = @[
            [[NSTextTab alloc] initWithType:NSRightTabStopType location:100],
            [[NSTextTab alloc] initWithType:NSRightTabStopType location:200],
            [[NSTextTab alloc] initWithType:NSRightTabStopType location:300]
        ];
        NSMutableParagraphStyle *myParagraphStyle = [[NSMutableParagraphStyle defaultParagraphStyle] mutableCopy];
        [myParagraphStyle setTabStops:myTabs];

        myFieldEditor = [NSTextView new]; // myFieldEditor is an instance variable
        [myFieldEditor setDefaultParagraphStyle:myParagraphStyle];

        [window setDelegate:self];
        [window fieldEditor:YES forObject:myTextField];
    }

And activate it for the text field in the windowWillReturnFieldEditor:toObject: delegate method:

- (id)windowWillReturnFieldEditor:(NSWindow *)sender toObject:(id)client
    {
        if (client == myTextField) return myFieldEditor;
        return nil;
    }

I even made sure that my custom field editor is indeed used by subclassing the NSTextFieldCell of my text field and logging the propagated field editor:

@implementation myTextFieldCell

- (NSText *)setUpFieldEditorAttributes:(NSText *)textObj
    {
        NSTextView *newTextObj = (NSTextView*)[super setUpFieldEditorAttributes:textObj];
        NSLog(@"STYLE: %@", [newTextObj defaultParagraphStyle]);
        return newTextObj;
    }

@end

Now, when I select the text in the text field, I get the following log output:

2017-11-02 11:51:07.432 Demo[94807:303] STYLE: Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (
    100R,
    200R,
    300R
), DefaultTabInterval 0, Blocks (null), Lists (null), BaseWritingDirection -1, HyphenationFactor 0, TighteningFactor 0.05, HeaderLevel 0

Which is exactly what is expected.

But still, the tab formatting disappears in the text field as soon as I select the text. I have no idea why this does not work.

So I’m stuck either way. If I set the allowsEditingTextAttributes property of NSTextField to YES, tab formatting is preserved when the text is selected, but my colored objects in the GUI unintentionally change to black. If I set the allowsEditingTextAttributes property to NO, the color panel behaves as it should, but the tab formatting is lost as soon as I select the text.

This is a very unfortunate case of Cocoa trying to be too smart and thereby making a completely trivial setup a huge issue.

Any ideas anyone?


Solution

  • OK, so I ended up with the suggestion that @Willeke (thanks!!) made in his comment to my question: To use NSTextView instead of NSTextField to implement my Multi-Line Label.

    I will first sum up why it seems impossible to do what I wanted with NSTextField, and then the solution with NSTextView.

    Why NSTextField doesn’t work

    As described, my idea for a solution was to customize the field editor for the NSTextField, setting the tab stops I need, so that I need not set NSTextField’s allowsEditingTextAttributes property to YES (which would unintentionally couple the text field to the color panel). This, I hoped, would preserve the tab stops of my attributed string’s paragraph style when I select the text in the text field and thereby activate the field editor.

    Extensive testing has shown that this does not work for several reasons:

    1. As @Willeke pointed out, setting the usesFontPanel property of NSTextView to NO also breaks the connection of the text view to the color panel (as desired). However, this does not work for the NSTextView that is the field editor of the NSTextField, because in this context, this setting is always overwritten by the allowsEditingTextAttributes property of the NSTextField: If allowsEditingTextAttributes is YES, the font and color panels are coupled regardless of the value of usesFontPanel, if it is NO, the font and color panels are decoupled regardless of the value of usesFontPanel.
    2. The idea to use the tab stops of a customized field editor instead of using the tab stops of my attributed string’s paragraph style (which would require allowsEditingTextAttributes to be YES) wouldn’t work, anyway, because the tab stops settings of the field editor are obviously always completely ignored by NSTextField, regardless of the value of the allowsEditingTextAttributes property. NSTextField always uses the evenly spaced default tab stops.

    Judging from intense googling, the other variant – setting allowsEditingTextAttributes to YES but somehow modifying NSColorPanel to not connect to the NSTextField nevertheless – is impossible to implement without recurring to private methods of NSColorPanel.

    How to implement the solution with NSTextView

    While it seems overkill to instantiate a complete NSTextView embedded in a clip view and a scroll view just to get the functionality of a text field, in the end it’s the easiest (or even only possible) solution.

    To make the scroll view disappear, you’ll have to basically uncheck everything in the NSScrollView’s Attribute inspector in IB, in particular Show Vertical Scroller. Set Draw Background and the border type to the kind of appearance you want; if you want to mimic a multi-line label (like I did), uncheck Draw Background and choose the invisible border type. In the Attribute inspector of the embedded NSTextView, also uncheck all attributes except Selectable, in particular Uses Font Panel.

    Make sure the size of the NSTextView is large enough to hold the complete content string to avoid unintentional scrolling effects and fix the text position. If your content string ends with a line brake, you’ll need enough space for an empty line beneath it. If you did not uncheck Draw Background and this does not look the way you want, don’t draw the background of either the NSScrollView or the NSTextView, select the invisible border for NSScrollView and then put an NSBox of the desired size and appearance beneath them.

    You can now set the attributed content string with:

    [[myTextView textStorage] setAttributedString:myAttributedString];
    

    Note that this does work although the editable property of NSTextView is set to NO since you’re modifying the NSTextStorage, not the NSTextView itself.

    But unfortunately, we’re not finished yet.

    When you’re using an NSTextField to display readonly data as you typically do in a Label, more often than not you would not want the text field to be part of your key view loop (that circles through your controls by pressing the Tab key). To achieve this, you can simply set the refusesFirstResponder property of NSTextField to YES. But NSTextView does not inherit from NSControl and therefore does not have this property. So in the end, we’ll have to subclass NSTextView to add the refusesFirstResponder property.

    The implementation overwrites becomeFirstResponder and goes like this:

    - (BOOL)becomeFirstResponder
        {
            if (!_refusesFirstResponder) return [super becomeFirstResponder];
    
            NSEvent *event = [NSApp currentEvent];
            if ([event type] == NSLeftMouseDown || [event type] == NSRightMouseDown) return [super becomeFirstResponder];
    
            NSView *validKeyView = ([event modifierFlags] & NSShiftKeyMask)? [[[self previousValidKeyView] previousValidKeyView] previousValidKeyView] : [self nextValidKeyView];
            [[NSOperationQueue mainQueue] addOperationWithBlock:^{[[self window] makeFirstResponder:validKeyView];}];
            return NO;
        }
    

    If refusesFirstResponder is NO, we simply return super’s implementation.

    If it is YES, we check if the NSTextView is about to become first responder because of a mouse click within it. If so, we also simply return super’s implementation, thereby allowing text selection with the mouse.

    Other than that, we forward the first responder request to the next or previous key view (depending on whether the Shift key was pressed) and return NO, refusing to become first responder. Determining the previous key view is a bit tricky because the closest previous key view is the embedding NSClipView we don’t want or need, but have to use because Interface Builder does not offer a “pure” NSTextView. Then comes the embedding NSScrollView, and only then the previous key view we actually want.

    Also, since we’re amidst a process which determines the first responder, we cannot simply invoke makeFirstResponder:, but have to postpone it to the next iteration of the run loop.

    Now that we have implemented refusesFirstResponder, we’ll still have to mimic NSTextField’s behavior to dismiss any text selection when it loses first responder state. We can do this in an NSText delegate method. Assuming we don’t need other delegate functionality, we can make our subclass its own delegate and add this delegate method:

    - (void)textDidEndEditing:(NSNotification*)notification
        {
            [[notification object] setSelectedRange:NSMakeRange(UINT64_MAX, 0)];
        }
    

    Finally, if we have to subclass, anyway, we might as well add a setAttributedString: convenience method.

    So what we’ll end up with is this:

    Header:

    #import <Cocoa/Cocoa.h>
    
    IB_DESIGNABLE
    
    @interface MyTextFieldLikeTextView : NSTextView <NSTextViewDelegate>
    
    @property IBInspectable BOOL    refusesFirstResponder;
    
    - (void)setAttributedString:(NSAttributedString*)attributedString;
    
    @end
    

    Implementation:

    #import "MyTextFieldLikeTextView.h"
    
    @implementation MyTextFieldLikeTextView
    
    - (void)awakeFromNib
        {
            [self setDelegate:self];
        }
    
    - (BOOL)becomeFirstResponder
        {
            if (!_refusesFirstResponder) return [super becomeFirstResponder];
    
            NSEvent *event = [NSApp currentEvent];
            if ([event type] == NSLeftMouseDown || [event type] == NSRightMouseDown) return [super becomeFirstResponder];
    
            NSView *validKeyView = ([event modifierFlags] & NSShiftKeyMask)? [[[self previousValidKeyView] previousValidKeyView] previousValidKeyView] : [self nextValidKeyView];
    
            [[NSOperationQueue mainQueue] addOperationWithBlock:^{[[self window] makeFirstResponder:validKeyView];}];
            return NO;
        }
    
    - (void)textDidEndEditing:(NSNotification*)notification
        {
            [[notification object] setSelectedRange:NSMakeRange(UINT64_MAX, 0)];
        }
    
    - (void)setAttributedString:(NSAttributedString*)attributedString
        {
            [[self textStorage] setAttributedString:attributedString];
        }
    
    @end
    

    Still a lot of effort only because Cocoa tries to outsmart us and insists on connecting the NSColorPanel to each and every NSTextField that allows for attributed text …