Skip to content

class-dump 3.3.1 is now available

Version 3.3.1 adds support for dumping 10.6 style protected binaries, so that you can once again look at the Finder, Dock, SystemUIServer, and a few other applications. This release fixes a crasher when trying to dump 64-bit files with the 32-bit version of class-dump. Now it prints an error message saying that this isn’t supported. Finally, it generates typedefs for template types that are used in methods, to make the method declarations shorter and more readable.

You can download this version from the class-dump project page. It is built for Mac OS X 10.5 or later.

As always, you can send bug reports to me at class-dump@codethecode.com, or nygard at gmail.com

class-dump 3.3 is now available

A new version of class-dump is now available. You can download it from the links on the class-dump project page. It is built for Mac OS X 10.5 or later.

Version 3.3 adds support for Snow Leopard, improves property handling, improves structure/union handling, fixes a bunch of bugs (including two crashers), and no doubt adds some new bugs.

Snow Leopard

There is a new load command (LC_DYLD_INFO) that contains a byte stream of information that dyld needs to load an image. This replaces the relocation info from the old dynamic symbol table; otool -rv outputs nothing for files built for 10.6. class-dump needs to understand this to correctly look up references to external class names. Version 3.2 shows a blank name for many superclass and category class names. This is fixed in version 3.3.

Snow Leopard also adds a new version of protected segments. These are recognized so that class-dump doesn’t spew out a bunch of garbage. Encrypted iPhone apps (indicated by the LC_ENCRYPTION_INFO command) are recognized for the same reason.

Bug Fixes

There are a lot of bug fixes. First of all, Mac OS X prefers 64-bit executables over 32-bit executables, so there was no need for a separate 32-bit executable. Now there is just one 32/64-bit universal executable. I’ve improved the parsing of C++ types, and class-dump can now make it through Safari and WebKit without complaint. Also fixed a crasher when trying to dump the 10.5 Finder.

Properties

More property attributes are now supported: dynamic, weak, strong, and non-atomic. Thanks to Joe Ranieri for sending a patch to handle these. Objective-C 2.0 categories and protocols now show their properties. Properties are now emitted when the first accessor method is encountered, and those accessors are not shown. Any remaining properties (usually dynamic ones, but there seem to be other cases where this can happen) are shown at the end of the method list. Properties declared in the optional section of protocols appear there in the output. The result is shorter, more readable output.

Property type strings can contain commas (for example, T^{map<int, int>=…},V_task), so we can’t just split the property attribute string on commas. Now class-dump parses the type, and then splits the rest.

Structure Handling

This is the process of trying to make sense out of all the structure and union definitions found in the files, and then to present it in a meaningful way.

Merging member names and types: Not all occurrences of a structure have member names, so it needs to merge member names when possible. For example, there might be both {_NSPoint=”x”f”y”f} and {_NSPoint=ff}. Of course, there are exceptions where structures with the same name have either the same underlying type with different member names, or a different underlying type altogether. An example of this is ^{?=@ii{_flags=b1b1b1b1b12}} from @category NSApplication (NSWindowCache) and ^{__NSRowHeightBucket=fi{_flags=”equalRowSizes”b1″reserved”b31}} from the _NSTableRowHeightStorage class. Anonymous structures can collide in the same way. It is common to have a structure of bitfield flags, and these can easily have the same layout but different member names. Types in methods never have member names, but worse still is that they reduce object types down to ids. That is, @”NSObject” appears as @ instead. Sometimes the compiler generates different names (such as $_2531) for multiple instances of the same union.

Determining how named and anonymous structures are declared: Named structures are generally declared at the top, unless they are only referenced once in an ivar or another structure, or if they are a special case (such as _flags), in which case they get expanded at the point where they occur. Any top level anonymous structures that are referenced by a method, or referenced from more than one place, need to be typedef’d and referred to by that name. And there are a few more special cases that should be handled.

Hmm, when I put it that way it sounds easy enough… But my specification was more like “Take a bunch of types, mix ‘em all together, and generate something pretty”. So there’s a lot of details and special cases to get right, and I ran into them one at a time. When I wrote that code, years ago, it took a lot of time to get it working on all of my test cases. I was tired of tweaking it, happy that it mostly worked, and ready to move on to something else. So I didn’t document it, and now I fear that code. I’ve made a couple attempts to figure out what it was doing and document it, but they didn’t get far.

I was working on the code after 3.2 was released. I decided to cache the parsed types, and pass the parsed types to the type formatter instead of passing a type string. Easy enough, but it introduced a bug, and that led me to the code that I feared. I ended up removing most of the old code and writing it from scratch. By the time is was mostly working again I was tired of tweaking it and ready to move on to something else. But this time I’ve documented it, so that I can remember how it works next year. Or next week.

What’s the result of that effort? Some structures get an actual object type, instead of just an id. Some structures pick up the correct member names, which were missing previously. Some structures have member names generated where they weren’t before. Some of the special cases (like _flag) are handled better.

I have also changed how typedef names are generated. Previously I just added an index to the name. This worked, but was extremely fragile. Unrelated changes to the code could alter the order that structures were processed, and change their name. Updates to frameworks could lead to large diffs in the output. The author of class-dump-z, kennytm, rightly criticized this (and other things!). Now I generate a hash based on the type string (after it’s been filled out with member names, and before the generic _field names have been added), and use that to create a unique typedef name. The results are great. There were only 6 diffs between the 10.5.5 and 10.5.8 AppKits with the new version, compared to 78 with the old.

The named structures are sorted by name and printed first, followed by the special cases. The anonymous structures are next, sorted by nested structure depth and then by the type string, followed by the special cases. #pragma marks are added before each section, and a #pragma mark – before each mach-o file, to make it easy to search for the next section.

As always, you can send bug reports to me at class-dump@codethecode.com. There were a lot of changes, and I haven’t tested it as thoroughly as I normally do.

class-dump 3.2 is now availabe

A new version of class-dump is now available. You can download it from the links on the class-dump project page. It is built for Mac OS X 10.5 or later.

This release includes support for Objective-C 2.0 on both 32 bit (iPhone) and 64 bit (Mac OS X). It shows class properties and handles all documented property attributes. Unrecognized property attributes are noted in a comment following the property declaration. class-dump also shows optional protocol methods. The Objective-C garbage collection status of each file is included in the output, as either “Unsupported”, “Supported”, or “Required”.

To support 64 bit files, class-dump is built as a 64 bit executable. A 32 bit executable (class-dump-32) is provided for 32 bit systems, but it doesn’t handle 64 bit files.

Internally, class-dump now stores the architectures as a cpu type, cpu subtype pair, instead of a string. This was necessary to handle unknown architectures such as the iPhone. For architectures without a recognized name, you can specify them as <cpu type>:<cpu subtype>, values are hex with an optional “0x” prefix. For example, you can use –arch 0xc:0×6 or simply –arch c:6. The –list-arches option will also list unknown architectures in this format.

If you find class-dump useful, I am now accepting donations to support its development. There is a donation button next to the download link on the project page. Thanks!

As always, you can send bug reports to me at class-dump@codethecode.com.

2009-07-05: I’ve fixed the .tar.bz2 packages. Ever since 3.1, my packaging script has been creating a .tar.gz, then bzip2 compressing the result and calling it a .tar.bz2.

Changing existing Help indexes to be local only

Today Pierre Igot writes about problems with built-in Help in Pages and how it downloads some help over the network. He found that when he clicked on a link in the help, it took up to a minute to do anything, with no indication about what was happening (downloading content from the internet), how long it would take or any way to stop the action.

There are three options that a developer can use when creating a help index that change this behavior. The Help Book content can be Internet-only, Internet-primary, or local-primary. (See Internet-Based Help Book Content.)

I looked at the help index for Pages. It’s located at “/Applications/iWork ’08/Pages.app/Contents/Resources/English.lproj/Pages 3 Help/Pages 3 Help.helpindex”. This is just a binary typed stream that only contains standard objects, so it was easy to write a tool to read the file. I tweaked it so that it doesn’t show the bulk of the index data. Here’s what part of Pages’ help index looks like:


"SKI_PREFER_NETWORK_FILES" = 1;
"SKI_REMOTE_ROOT" = "http://helposx.apple.com/pageshelpr3/English/";
"SKI_USE_REMOTE_ROOT" = 1;
"SKI_VERSIONS" =     {
    "SKI_CORE_FOUNDATION" = 368;
    "SKI_FOUNDATION" = 567;
    "SKI_HELP_INDEXER" = 17;
    "SKI_SEARCH_KIT" = 147;
    "SKI_SYSTEM_BUILD" = 8P135;
};

This is pretty easy to understand, but with a little experimentation with Help Indexer I verified that the first three keys (SKI_PREFER_NETWORK_FILES, SKI_REMOTE_ROOT, SKI_USE_REMOTE_ROOT) are the only changes between the three different options. Removing the first two keys and changing the value of SKI_USE_REMOTE_ROOT to 0 changes it to a local-only version.

HelpIndexTool

This is the little tool I created. Running it with no arguments shows the usage. Running it with a single argument (the path the a help index) will display the contents of the file (excluding the actual index data). Running it with two arguments will create a local-only version of the help index at the path specified by the second argument.

So if you wanted to try this on the Pages help index, you would do:


./HelpIndexTool "/Applications/iWork '08/Pages.app/Contents/Resources/English.lproj/Pages 3 Help/Pages 3 Help.helpindex" ~/out.helpindex

And then backup the original file, and replace the original “Pages 3 Help.helpindex” with the out.helpindex file in your home directory. Relaunch Pages and it should be good to go.

You can download HelpIndexTool-2008-02-01.dmg here. It includes the project source and compiled tool (Universal).

If you have the developer tools installed, you could try just recreating the help index with Help Indexer, but there are other options that you’d want to make sure were the same as the original. In particular, you can specify the language for the stop words, or specify a custom file of stop words. My tool doesn’t change any of these other options.

Working around opendiff race conditions for Subversion integration

When I started using Subversion, I wrote a little Python script to act as the diff command, calling opendiff. This makes ‘svn diff’ show all the changes in FileMerge. It worked, but I soon ran into a problem. Sometimes it would launch more than one copy of FileMerge. For a time I just made sure FileMerge was running before I did a diff, but that was a pain.

So last year I looked at it, and came up with a solution. I wrote a simple program that used Launch Services (via NSWorkspace) to launch FileMerge synchronously, and ran that before I ran opendiff.

This worked great until 10.5 came out, but then I noticed the multiple FileMerges occurring some of the time. I finally took a closer look at this.

With a little debugging I discovered that opendiff doesn’t use Launch Services at all. Instead, it checks for the existence of a named port, ‘com.apple.DiffMergeController4.0′. If it exists, it uses Distributed Objects to tell FileMerge to compare the files. Otherwise, it uses an NSTask to run FileMerge with ‘-left <left-file> -right <right-file>’ arguments. There seems to be no protection against launching FileMerge more than once.

You can see this for yourself by making sure FileMerge isn’t running, and then doing ‘/Developer/Applications/Utilities/FileMerge.app/Contents/MacOS/FileMerge & opendiff file1.txt file2.txt’. This is almost guaranteed to leave two FileMerges running.

My solution was to update my Launcher tool to wait until the named port existed before returning. So far this seems to work.


NSPort *port;

port = [[NSPortNameServer systemDefaultPortNameServer] portForName:@"com.apple.DiffMergeController4.0"];

if (port == nil) {
    result = [[NSWorkspace sharedWorkspace] launchAppWithBundleIdentifier:@"com.apple.FileMerge"
                                            options:NSWorkspaceLaunchWithoutActivation
                                            additionalEventParamDescriptor:nil
                                            launchIdentifier:NULL];
    if (result) {
        port = [[NSPortNameServer systemDefaultPortNameServer] portForName:@"com.apple.DiffMergeController4.0"];
        while (port == nil) {
            usleep(50000);
            port = [[NSPortNameServer systemDefaultPortNameServer] portForName:@"com.apple.DiffMergeController4.0"];
        }
    }
}

(I probably don’t even need to bother checking the port first.)

So I have my opendiff2 script installed somewhere in my search path, and configured ~/.subversion/config with the following:


[helpers]
diff-cmd = opendiff2

I’ve also compiled my Launcher project and have it in the search path.

opendiff2 creates a /tmp/opendiff2 directory, and copies the files to be compared into directories under that, named after the parent process id. This effectively groups files compared by one ‘svn diff’ session together.

Private API to the rescue

I started using a new method, -[NSView viewWillDraw], in one of my views for performing layout. This is a really handy method. It supports further dirtying of the view and frame changes.

But while I was converting my code I discovered a case where my entire view was being redrawn. This was odd, so I made a note to look at it after my conversion was done. I go to quite some trouble to make sure I only dirty the parts of my view that I need to. When I looked through my logs, I didn’t see any calls to -setNeedsDisplay: or -setNeedsDisplayInRect: that would explain why it was redrawing the whole view.

I was ready to blame -viewWillDraw, so I set out to create a simple test case for it. Fortunately that method isn’t at fault. What’s really happening is that changing the frame causes the entire view to be redisplayed!

This is different from the 10.4 behaviour. The documentation for -[NSView setFrame:] says:

This method, in setting the frame rectangle, repositions and resizes the receiver within the coordinate system of its superview. It neither redisplays the receiver nor marks it as needing display. You must do this yourself with display or setNeedsDisplay:.

I ran my test app on 10.4, and it did what I expected. But it didn’t explain what was causing this effect, and how to fix it.

I was reminded of instrumentObjcMessageSends() by a post on Dave Dribin’s blog: Tracing Objective-C messages. So I enabled logging just before I changed the frame, ran the test on 10.5 and 10.4, and compared the results.

There are several changes. In the 10.5 run I see this:


- MyView NSView setFrameSize:
- MyView NSView inLiveResize
- MyView NSView _autoInvalidateBeforeFrameSizeChange
- MyView NSView _setWindowNeedsDisplayInViewsDrawableRect

And a bit later:


- MyView NSView inLiveResize
- MyView NSView _autoInvalidateAfterFrameSizeChange
- MyView NSView _setWindowNeedsDisplayInViewsDrawableRect
- MyView NSView visibleRect

Well, I wonder what _autoInvalidateAfterFrameSizeChange does. First let’s class-dump it and see what classes implement it:


% class-dump -A /System/Library/Frameworks/AppKit.framework -f _autoInvalidate
/*
 *     Generated by class-dump 3.1.2.
 *
 *     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2007 by Steve Nygard.
 */

@interface NSTextView : NSText <NSTextInput, NSUserInterfaceValidations, NSTextInputClient>
- (void)_autoInvalidateBeforeFrameSizeChange;	// IMP=0x0055d938
- (void)_autoInvalidateAfterFrameSizeChange;	// IMP=0x0055d93c

@interface NSView (NSInternal)
- (void)_autoInvalidateBeforeFrameSizeChange;	// IMP=0x005c504c
- (void)_autoInvalidateAfterFrameSizeChange;	// IMP=0x005c5058

Interesting. I wonder what NSTextView’s implementation of these do.


(gdb) x/20i 0x0055d938
0x55d938 <-[NSTextView _autoInvalidateBeforeFrameSizeChange]>:     blr
0x55d93c <-[NSTextView _autoInvalidateAfterFrameSizeChange]>:      blr
0x55d940 <-[NSTextView setBoundsSize:]>:     mflr    r0

Aha! It doesn’t do anything. I like it. Indeed, testing in both my sample and real applications shows that this fixes the problem.


- (void)_autoInvalidateBeforeFrameSizeChange;
{
    // Do nothing
}

- (void)_autoInvalidateAfterFrameSizeChange;
{
    // Do nothing
}

Sure, Apple doesn’t want us to use private API, but as this illustrates there are cases where they leave us no choice.

By the way, the AppKit release notes say nothing about this new behaviour, not even a hint like there was for the focus ring bleed regions.

I did notice the following in the documentation for -[NSView setFrameSize:]:

In Mac OS X version 10.4 and later, you can override this method to support content preservation during live resizing. In your overridden implementation, include some conditional code to be executed only during a live resize operation. Your code must invalidate any portions of your view that need to be redrawn.

So maybe the bug is mixed up with that. I’ve filed a bug report. In the meantime I’m just happy there’s such an easy workaround.

Using NSTrackingArea for cursor rects

As I said before, Leopard adds a new class, NSTrackingArea, that is now the preferred way of setting up cursor rectangles. It can also handle mouse entered/exited and mouse moved events, but right now I’m only interested in cursor updates.

I didn’t really gain anything from using NSTrackingArea in this case. I still have to clip the rects against the visible area.

I replaced -resetCursorRects with -updateTrackingAreas. The old tracking areas aren’t discarded before this is called, so I have to remove the old ones first. I store the cursor in the NSTrackingArea userInfo so that I can get it in -cursorUpdate:.


- (void)updateTrackingAreas;
{
    NSRect visibleRect;
    NSTrackingArea *trackingArea;

    [super updateTrackingAreas];

    for (trackingArea in [self trackingAreas])
        [self removeTrackingArea:trackingArea];

    visibleRect = [self visibleRect];

    for (STTableColumn *tableColumn in [self tableColumns]) {
        NSRect cursorRect;

        cursorRect = NSIntersectionRect([self resizeCursorFrameForTableColumn:tableColumn], visibleRect);
        if (NSIsEmptyRect(cursorRect) == NO) {
            NSCursor *cursor;

            cursor = [tableColumn resizeCursor];
            if (cursor != nil) {
                trackingArea = [[NSTrackingArea alloc] initWithRect:cursorRect
                                                       options:(NSTrackingCursorUpdate|NSTrackingActiveInKeyWindow)
                                                       owner:self
                                                       userInfo:[NSDictionary dictionaryWithObject:cursor forKey:@"cursor"]];
                [self addTrackingArea:trackingArea];
                [trackingArea release];
            }
        }
    }
}

I’ve removed the -disableCursorRects/-enableCursorRects, and it seems to work fine without a replacement. If I did need to disable them, since I control cursor updates in -cursorUpdate: I could just set a flag and not update them when the flag is set.

I call -updateTrackingAreas directly instead of using -[NSWindow invalidateCursorRectsForView:].

Finally, here’s my -cursorUpdate: method.


- (void)cursorUpdate:(NSEvent *)event;
{
    NSPoint hitPoint;
    NSTrackingArea *trackingArea;
    NSCursor *cursor;

    trackingArea = [event trackingArea];
    cursor = [[trackingArea userInfo] objectForKey:@"cursor"];

    hitPoint = [self convertPoint:[event locationInWindow] fromView:nil];
    if (cursor != nil && [self mouse:hitPoint inRect:[trackingArea rect]]) {
        [cursor set];
    } else {
        [[NSCursor arrowCursor] set];
    }
}

It’s important to use -[NSView mouse:inRect:] instead of NSPointInRect(), otherwise slowly moving the cursor across the top or bottom edge of the tracking area won’t properly update the cursor. And if the tracking area doesn’t cover the entire view, you also get a -cursorUpdate: when the mouse leaves a tracking area, so you need to watch for this case and use a default cursor.

I think in most cases you want to make sure the tracking areas are clipped to the visible rect. The -cursorUpdate: is only called at the transition from one tracking area to another, and it gets sent down the responder chain starting at the view under the mouse. So if you have a tracking area that extends above the top edge of a view, your view won’t get a cursor update when you cross the top edge of the tracking area, and so you won’t change the cursor. You don’t get another update when the mouse enters your view, because it’s still in the same tracking area.

This is slightly different from the old way, because NSWindow was setting the cursor for you. But off the top of my head I can’t imagine a case where you would want the tracking area to extend outside of the view, so this shouldn’t be a problem. Just something to keep in mind.

That’s it, there really wasn’t much to change.

Old style cursor rects

Leopard adds a new class, NSTrackingArea, that is now the preferred way of setting up cursor rectangles. I’m looking at switching some old code to use the new method, but first I’ll describe how it currently works.

The old way

Your NSView subclass implements -resetCursorRects to add the cursor rects. The old ones have already been removed, so you just need to use -[NSView addCursorRect:cursor:] to add the new ones. When the view changes in a way that makes them invalid, such as changing the frame, the cursor rects are invalidated and -resetCursorRects will be called again to set them up. If you make other changes to your view that affect the position of the cursor rects, you can use -[NSWindow invalidateCursorRectsForView:] to invalidate them.

There are two things you need to be careful with. First, you should intersect the cursor rects with the visible rect before adding them, since you won’t get mouse events in these hidden areas and it’s misleading to change the cursor for them. Second, if you change the frame in a modal event loop, the cursors will be reset for each change and the cursor will flicker oddly. You can disable cursor rect updates by calling -[NSWindow disableCursorRects] before your loop, and enable them at the end with -[NSWindow enableCursorRects].

An example

I’ll use a custom table header view as an example. It needs to show resize cursors at the right edge of the columns. This is used in a custom table view, not subclassed from NSTableView, so while the classes correspond to their Cocoa counterparts, they don’t inherit any behavior from them.

The table columns have a minimum and maximum size, and so they need to show different cursors based upon whether they can resize left, right, or in both directions.


@implementation STTableColumn

- (NSCursor *)resizeCursor;
{
    BOOL canShrink, canGrow;

    canShrink = width > minimumWidth;
    canGrow = width < maximumWidth;
    if (canShrink && canGrow) {
        return [NSCursor resizeLeftRightCursor];
    } else if (canShrink) {
        return [NSCursor resizeLeftCursor];
    } else if (canGrow) {
        return [NSCursor resizeRightCursor];
    }

    return nil;
}

@end

The following method clips the proposed cursor rect against the visible rect.


@interface STTableHeaderView

- (void)resetCursorRects;
{
    NSRect visibleRect;

    visibleRect = [self visibleRect];

    for (STTableColumn *tableColumn in [self tableColumns]) {
        NSRect cursorRect;

        cursorRect = NSIntersectionRect([self resizeCursorFrameForTableColumn:tableColumn], visibleRect);
        if (NSIsEmptyRect(cursorRect) == NO) {
	    NSCursor *cursor;

	    cursor = [tableColumn resizeCursor];
	    if (cursor != nil)
                [self addCursorRect:cursorRect cursor:cursor];
        }
    }
}

While editing a column title, the field editor covers the title and so we need to make sure the cursor rect doesn't overlap the field editor.


- (NSRect)resizeCursorFrameForTableColumn:(STTableColumn *)aTableColumn;
{
    NSRect rect;

    if (aTableColumn == nil)
        return NSZeroRect;

    rect = [self bounds];
    if ([aTableColumn index] == editingColumnIndex) {
        rect.size.width = 2;
        rect.origin.x = [aTableColumn maxXPosition];
    } else if ([aTableColumn index] + 1 == editingColumnIndex) {
        rect.size.width = 3;
        rect.origin.x = [aTableColumn maxXPosition] - 3;
    } else {
        rect.size.width = 5;
        rect.origin.x = [aTableColumn maxXPosition] - 3;
    }

    return rect;
}

Changing a column's width changes the frame, so we don't need to invalidate cursor rects in that case. But during the modal event loop we do disable cursor updates to avoid the cursor flickering (both the frame change and auto-scrolling would otherwise reset the cursor). When we do change the width, we manually reset the cursor. Here are the interesting parts:


- (void)startResizingColumn:(NSEvent *)mouseDownEvent;
{
    ...
    [[self window] disableCursorRects];

    while (1) {
        NSEvent *event;
        event = [NSApp nextEventMatchingMask:(NSLeftMouseDraggedMask | NSLeftMouseUpMask | NSPeriodicMask)
                       untilDate:[NSDate distantFuture]
                       inMode:NSEventTrackingRunLoopMode
                       dequeue:YES];

        ...

        if ([event type] == NSLeftMouseDragged) {
            ...
            if ([tableColumn width] != newWidth) {
                [tableColumn setWidth:newWidth];
                [self autoscroll:event];

                // Make sure the cursor changes as we hit min/max widths
                [[tableColumn resizeCursor] set];
            }
        }
        ...
    }

    [[self window] enableCursorRects];
}

When columns are reordered, the frame doesn't change so we do need to invalidate them.

And that's all there is to it.

iCal 3: Shake, Rattle, and Scroll

It’s not my fault. I just wanted to move the iCal window out of the way so that I could see Pierre Igot’s blog entry on iCal 3 hyphenation. I was going to add an appointment and watch the problem in action. But when I moved the window, the day view started scrolling up.

This works best if you have a fast mouse acceleration. Go into the day view, and scroll it to the bottom. Click in the toolbar area above the day view. Try to get close to the day view while still being able to drag the window around. Now, while you’re dragging the window, shake it down and then up again. If you’re fast enough the day view will start scrolling up.

Leopard hates custom views that draw focus rings

The Cocoa release notes say this:

To help guarantee correct redraw of focus rings, AppKit may automatically draw additional parts of a window beyond those that application code marked as needing display. It may do this for the first responder view in the application’s key window, if that first responder view’s focusRingType property is set to a value other than NSFocusRingTypeNone. Any view that can become first responder, but doesn’t draw a focus ring, should have its focusRingType set to NSFocusRingTypeNone to avoid unnecessary additional redraw.

What this means is that when your view calls NSSetFocusRingStyle(NSFocusRingOnly), this ends up calling -[NSWindow _setLastFocusRingView:bleedRegion:], with this view as the first argument. From that point forward, until another view becomes first responder, the entire view will be redrawn any time part of it becomes dirty. This effectively kills your drawing performance.

Setting the view’s focusRingType to NSFocusRingTypeNone has no effect.

There are a few private methods that AppKit is using here.


@interface NSView (NSPrivateFocusRingSupport)
- (id)_focusRingBleedRegion;
- (id)_focusRingClipAncestor;
@end

@interface NSView : NSResponder <NSAnimatablePropertyContainer>
- (NSRect)_focusRingVisibleRect;
@end

NSView’s implementation of _focusRingBleedRegion roughly does this:

  • Converts the visible rect to window base coordinates, scales it, and then insets it be -3 pixels on each side.
  • Calls -_focusRingClipAncestor, and converts that view’s _focusRingVisibleRect to window base coordinates.
  • Creates an NSRegion out of the intersection of two of these rectangles (not exactly sure which two).
  • Insets a rectangle and subtracts that from the region.
  • Returns this region.

The subclasses _NSBroswerScrollView, NSButton, and NSTableView, each implement this method, presumably because they need to. But if they need to, isn’t it likely that other views might need to as well? Except that it’s private, and uses an entirely private class, NSRegion.

Fortunately my workaround doesn’t need to use this method. A little experimentation showed that the focus view and region can be nil. So I just need to override -[NSWindow setLastFocusView:bleedRegion:] and pass on nil values for my particular view:


@implementation NSView (STExtensions)

// This is used on 10.5 by my NSWindow category to work around a problem with focus ring bleed regions.  The default is YES,
// meaning just do the normal behavior.  Subclasses can return NO to disable AppKit's automatic bleed region dirtying for them.
- (BOOL)canControlFocusRingBleedRegion;
{
    return YES;
}

@end

@implementation NSWindow (STExtensions)

+ (void)load;
{
    if (self == [NSWindow class]) {
        Method originalMethod, replacementMethod;

        originalMethod = class_getInstanceMethod(self, @selector(_setLastFocusRingView:bleedRegion:));
        replacementMethod = class_getInstanceMethod(self, @selector(replacement_setLastFocusRingView:bleedRegion:));
        method_exchangeImplementations(originalMethod, replacementMethod);
    }
}

- (void)replacement_setLastFocusRingView:(id)view bleedRegion:(id)region;
{
    if ([view canControlFocusRingBleedRegion]) {
        [self replacement_setLastFocusRingView:view bleedRegion:region]; // Not really recursive.
    } else {
        // We can't control it, so disable it.
        [self replacement_setLastFocusRingView:nil bleedRegion:nil]; // Not really recursive.
    }
}

Then my view just has to return NO from -canControlFocusRingBleedRegion. This works like a charm, assuming your view draws it’s focus ring correctly in all cases, which mine does.

Here’s what the Cocoa release notes have to say about private methods:

The last point (Increased restricted visibility of private symbols in the runtime) is especially important, since it would prevent applications using these symbols from launching in future releases. As always: Please do not access undeclared APIs in your applications. This includes undeclared classes, methods, and instance variables. If you have a strong need for a private API and have no workaround, please let us know.

This is a perfect example of Apple leaving us no choice but to use private API. I’ve filed a bug report about this case.