Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using an NSNumberFormatter in an NSPopover

Is there any way to get an NSNumberFormatter (or presumably any other NSFormatter) to work in an NSPopover?

The NSTextField's value in the popover is bound to an NSViewController's representedObject. When an invalid number is entered into the field (for example, "asdf") a sheet saying the value is invalid is presented in the NSWindow that contains the NSView that presented the popover.

As soon as you click OK, you get the following backtrace:

* thread #1: tid = 0x4e666a, 0x00007fff931f9097 libobjc.A.dylib`objc_msgSend + 23, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=EXC_I386_GPFLT)
frame #0: 0x00007fff931f9097 libobjc.A.dylib`objc_msgSend + 23
frame #1: 0x00007fff8a1fa6c8 AppKit`-[NSTextView(NSSharing) becomeKeyWindow] + 106
frame #2: 0x00007fff8a080941 AppKit`-[NSWindow(NSWindow_Theme) acquireKeyAppearance] + 207
frame #3: 0x00007fff8a0800df AppKit`-[NSWindow becomeKeyWindow] + 1420
frame #4: 0x00007fff8a07f5c6 AppKit`-[NSWindow _changeKeyAndMainLimitedOK:] + 803
frame #5: 0x00007fff8a1a205d AppKit`-[NSWindow _orderOutAndCalcKeyWithCounter:stillVisible:docWindow:] + 1156
frame #6: 0x00007fff8a0876c5 AppKit`-[NSWindow _reallyDoOrderWindow:relativeTo:findKey:forCounter:force:isModal:] + 3123
frame #7: 0x00007fff8a0867f0 AppKit`-[NSWindow _doOrderWindow:relativeTo:findKey:forCounter:force:isModal:] + 786
frame #8: 0x00007fff8a086470 AppKit`-[NSWindow orderWindow:relativeTo:] + 162
frame #9: 0x00007fff8a1a1425 AppKit`__18-[NSWindow _close]_block_invoke + 443
frame #10: 0x00007fff8a1a1230 AppKit`-[NSWindow _close] + 370
frame #11: 0x00007fff8a2d0565 AppKit`__106-[NSApplication(NSErrorPresentation) presentError:modalForWindow:delegate:didPresentSelector:contextInfo:]_block_invoke3221 + 50
frame #12: 0x00007fff8a2d02f7 AppKit`-[NSApplication(NSErrorPresentation) _something:wasPresentedWithResult:soContinue:] + 18
frame #13: 0x00007fff8a28fe9d AppKit`-[NSAlert didEndAlert:returnCode:contextInfo:] + 90
frame #14: 0x00007fff8a28f8c2 AppKit`-[NSWindow endSheet:returnCode:] + 368
frame #15: 0x00007fff8a28f49d AppKit`-[NSAlert buttonPressed:] + 107
frame #16: 0x00007fff8a1543d0 AppKit`-[NSApplication sendAction:to:from:] + 327
frame #17: 0x00007fff8a15424e AppKit`-[NSControl sendAction:to:] + 86
frame #18: 0x00007fff8a1a0d7d AppKit`-[NSCell _sendActionFrom:] + 128
frame #19: 0x00007fff8a1ba715 AppKit`-[NSCell trackMouse:inRect:ofView:untilMouseUp:] + 2316
frame #20: 0x00007fff8a1b9ae7 AppKit`-[NSButtonCell trackMouse:inRect:ofView:untilMouseUp:] + 487
frame #21: 0x00007fff8a1b91fd AppKit`-[NSControl mouseDown:] + 706
frame #22: 0x00007fff8a13ad08 AppKit`-[NSWindow sendEvent:] + 11296
frame #23: 0x00007fff8a0d9744 AppKit`-[NSApplication sendEvent:] + 2021
frame #24: 0x00007fff89f29a29 AppKit`-[NSApplication run] + 646
frame #25: 0x00007fff89f14803 AppKit`NSApplicationMain + 940

The registers at the time of the crash in objc_msgSend are:

(lldb) reg read
General Purpose Registers:
   rax = 0x0000610000190740
   rbx = 0x0000610000190740
   rcx = 0x0000000000000080
   rdx = 0x00007fff8a97fd93  "currentEditor"
   rdi = 0x0000610000190740
   rsi = 0x00007fff8a9612bf  "respondsToSelector:"
   rbp = 0x00007fff5fbfeae0
   rsp = 0x00007fff5fbfeab8
    r8 = 0x000000000000002e
    r9 = 0xffff9fffffeb1bbf
   r10 = 0x00007fff8a9612bf  "respondsToSelector:"
   r11 = 0xbaddbe5c3e96bead
   r12 = 0x0000610000053830
   r13 = 0x00007fff931f9080  libobjc.A.dylib`objc_msgSend
   r14 = 0x000060000012a500
   r15 = 0x00007fff931f9080  libobjc.A.dylib`objc_msgSend
   rip = 0x00007fff931f9097  libobjc.A.dylib`objc_msgSend + 23
rflags = 0x0000000000010246
    cs = 0x000000000000002b
    fs = 0x0000000000000000
    gs = 0x00000000c0100000

I'm guessing that's because the transient popover's window went away after the sheet was displayed, and so is the current editor and any object that can respond to the selector.

Setting the popover behavior to NSPopoverBehaviorSemitransient helps somewhat, but if the popover gets dismissed with an invalid value in the text field, the exception is still thrown.

At this point, all I can think of to avoid this problem is manual validation of the numeric values. Yuck.

Update 1

As Brian Webster discovered below, this is a fundamental problem with AppKit.

Since my validation needs were pretty simple (just positive integers), the workaround was to do manual validation in the KVC object that's used as the representedObject in the NSViewController displayed by the NSPopover. Since NSTextField really wants to use string values, -valueForKey: and -setValue:forKey: are used to transform the scalar values. When you turn on "validates immediately" for the bound value in the text field, the validation method is called anytime the text field changes.

(And before you ask, a NSValueTransformer can't do this work, since it's not involved in the validation process. It only gets called when the fields are populated or changes are saved. I wanted feedback as soon as the user had entered some invalid data -- as an NSFormatter would do.)

Here's the gist of what I did:

- (id)valueForKey:(NSString *)key
{
    if ([key isEqualToString:@"property1"]) {
        return [NSString stringWithFormat:@"%zd", _property1];
    }
    else if ([key isEqualToString:@"property2"]) {
        return [NSString stringWithFormat:@"%zd", _property2];
    }
    else {
        return [super valueForKey:key];
    }
}


- (BOOL)validateValue:(inout id *)ioValue forKey:(NSString *)inKey error:(out NSError **)outError
{
    if (! *ioValue) {
        *ioValue = @"0";
    }
    else if ([*ioValue isKindOfClass:[NSString class]]) {
        NSString *inputString = [[(NSString *)*ioValue copy] autorelease];
        inputString = [inputString stringByReplacingOccurrencesOfString:@"," withString:@""];
        NSInteger integerValue = [inputString integerValue];
        if (integerValue < 0) {
            integerValue = -integerValue;
        }
        *ioValue = [NSString stringWithFormat:@"%zd", integerValue];
    }

    return YES;
}

- (void)setValue:(id)value forKey:(NSString *)key
{
    if ([value isKindOfClass:[NSString class]]) {
        if ([key isEqualToString:@"property1"]) {
            _property1 = [value integerValue];
        }
        else if ([key isEqualToString:@"property2"]) {
            _property2 = [value integerValue];
        }
        else {
            [super setValue:value forKey:key];
        }
    }
    else {
        [super setValue:value forKey:key];
    }
}

Now I need to take a shower.

Update 2

Thanks to a couple of helpful hint from @PixelCutCompany about how they do things in the PaintCode app:

https://twitter.com/PixelCutCompany/status/441695942774104064 https://twitter.com/PixelCutCompany/status/441696198140125184

I came up with this:

@interface PopupNumberFormatter : NSNumberFormatter

@end

@implementation PopupNumberFormatter

- (BOOL)getObjectValue:(out id *)anObject forString:(NSString *)aString range:(inout NSRange *)rangep error:(out NSError **)error
{
    NSNumber *minimum = [self minimum];
    NSNumber *maximum = [self maximum];

    if (aString == nil || [aString length] == 0) {
        if (minimum) {
            *anObject = minimum;
        }
        else if (maximum) {
            *anObject = maximum;
        }
        else {
            *anObject = [NSNumber numberWithInteger:0];
        }
    }
    else {
    if (! [super getObjectValue:anObject forString:aString range:rangep error:nil]) {
        // if the superclass can't parse the string, assign a reasonable default
        if (minimum) {
            *anObject = minimum;
        }
        else if (maximum) {
            *anObject = maximum;
        }
        else {
            *anObject = [NSNumber numberWithInteger:0];
        }
    }
    else {
        // clamp the parsed value to a minimum and maximum (if set)
        if (minimum && [*anObject compare:minimum] == NSOrderedAscending) {
            *anObject = minimum;
        }
        else if (maximum && [*anObject compare:maximum] == NSOrderedDescending) {
            *anObject = maximum;
        }
    }
    }

    return YES;
}

@end

Basically, you can avoid the problems with the sheet or dialog by always supplying a valid value. The code above takes into account minimum and maximum values when it assigns default values. The subclass also takes into account nil or empty strings as well as clamping values.

This make me feel much less dirty.

like image 766
chockenberry Avatar asked Mar 05 '14 23:03

chockenberry


1 Answers

I set up a test project to see if I could reproduce this, and I got the same kind of behavior. Here's what appears to be the sequence of events:

  1. When you hit enter in the text field, that triggers the binding, which attempts to validate the value in the field via the NSNumberFormatter.
  2. When it fails, the bindings system presents an NSError object via the responder chain. This bubbles up to NSApplication, which presents the error as a sheet on the window.
  3. The appearance of the sheet triggers the popover being closed, which in turn triggers the same binding again, which tries to display another error. However, since there's already a sheet displayed on the window, the second error never gets displayed. If you change the binding options and enable "Always Present Application Modal Alerts" though (which will display the error in a separate window instead of a sheet), you'll see two separate alert windows show up.

I think it's this error-within-an-error that throws AppKit for a loop, and somewhere down the road when it tries to mess with the field editor (that's the NSTextView in the stack trace), it ends up messaging the now-deallocated NSTextField.

The best workaround I've found is to implement -willPresentError: in the NSViewController subclass I'm using to control the popover, like so:

- (NSError *)willPresentError:(NSError *)error
{
    NSMutableDictionary* userInfo = [[error userInfo] mutableCopy];

    [self.numberTextField unbind:@"value"];
    [userInfo setValue:nil forKey:NSRecoveryAttempterErrorKey];
    [userInfo setValue:nil forKey:NSLocalizedRecoveryOptionsErrorKey];
    return [NSError errorWithDomain:[error domain] code:[error code] userInfo:userInfo];
}

The unbind: call removes the binding, so that it doesn't attempt to revalidate the text field when the popover closes. Since the popover is going to disappear when the error gets displayed anyway, this shouldn't have any ill effect, assuming you're creating the popover from scratch each time it's displayed and not reusing it.

Also, since the "OK" and "Discard Change" buttons no longer make much sense when the field they're referring to is gone, I remove the binding system's recovery attempter from the error before passing it on to AppKit for display. This way, it just says "The value X is invalid" with an "OK" button that does nothing but dismiss the error window.

Note that this only works if "Always Present Application Modal Alerts" is enabled on the binding. Otherwise, the willPresentError: method doesn't seem to get called by AppKit if it's going to be displaying the error as a sheet, at least not on the view controller. You might be able to insert the logic somewhere else in the responder chain though, e.g. the main window's controller, if you want to keep the sheet behavior.

I'll leave it to you to decide whether or not this is more or less ugly than validating the values manually. :)

like image 188
Brian Webster Avatar answered Oct 18 '22 16:10

Brian Webster