2012-09-08

The Find and Replace menu items and accelerator keys depend on the responder chain to work. They use a common action -performFindPanelAction: that must be implemented by the application's views. The views must also implement -validateMenuItem: or -validateUserInterfaceItem: to indicate whether the menu items should be enabled. There is a very old bug in WebKit wherein a WebView provides a no-op -performFindPanelAction: and disables all of the menu items. The WebView provides the necessary search abilities, but the find panel itself does not exist. As a programmer, you can implement your own search mechanism outside the WebView, but whenever the WebView is the first responder (after the user scrolls for example) then the WebView will break the Find keys.

You can subclass WebView and provide your own -performFindPanel: and -validateUserInterfaceItem: functions, but the keys will still not work. See this WebKit SDK discussion for an idea about why this fails. My own tests show that the first responder is indeed another class titled WebHTMLView. How annoying!

// For an NSDocument-based application that loads content into a WebView, wait for
// the WebView's object hierarchy to be set up, then walk the responder chain.

- (void)windowControllerDidLoadNib:(NSWindowController *)aController {
	// ...
	[self performSelector: @selector(getResponderInfo:) withObject: self afterDelay: 4.0];
}

-(IBAction) getResponderInfo: (id) sender {
	NSResponder *resp, *temp;

	resp = [[webView window] firstResponder];
	logObjectClassInfo(resp);
	for ( ; nil != resp; resp = [resp nextResponder]) {
		NSLog( @"Responder = %@ -- %s", resp, [resp respondsToSelector: @selector(performFindPanelAction:)] ? "YES" : "no");
		temp = resp;
		while (nil != temp && [temp respondsToSelector: @selector(superview)]) {
			temp = [(NSView *) temp superview];
			NSLog( @"-- superview = %@ -- %s", temp, [temp respondsToSelector: @selector(performFindPanelAction:)] ? "YES" : "no");
		}
	}
}

So I need to override methods in existing class which I do not want to rebuild from source. Objective-C categories can replace existing methods, but the new methods hide the old methods. I want to wrap the old methods so that I only change a small aspect of their behavior. If I controlled the allocation and initialization of the objects, I could subclass the WebHTMLView. That is not an option since WebView and WebFrame build up their hierarchy of objects with little input from the user. Older versions of OS X had a concept of "class posing" where the programmer could create a subclass and have that subclass be used for all new objects of that type. It had some quirks and it is now deprecated. Per the NSObject API documentation: "Posing is deprecated in Mac OS X v10.5. The poseAsClass: method is not available in 64-bit applications on Mac OS X v10.5."

My last resort is use method swizzling. By using low level functions of the Objective-C runtime, I can save references to the original methods of the WebHTMLView class, implement new wrapper methods that call the original methods, and insert the wrapper methods into the class's method table.

One strategy for doing that would be to create a category on WebHTMLView with my modified methods, then use the runtime method_exchangeImplementations() to swap my methods with the old methods. This would lead to code almost as clean as a subclass:

@interface WebHTMLView (MyHack)
- (id) myEnhancedWebViewForObject;
-(BOOL)oldValidateUserInterfaceItem: (id) item;
-(void) oldPerformFindPanelAction: (id) sender;
@end

@implementation WebHTMLView (MyHack)
- (id) myEnhancedWebViewForObject {
	NSResponder *resp;
	for (resp = [self nextResponder]; nil != resp; resp = [resp nextResponder]) {
		if ([resp isKindOfClass: [MyEnhancedWebView class]]) return resp;
		if ([resp isKindOfClass: [WebView class]]) return nil;
	}
	return nil;
}
-(BOOL)oldValidateUserInterfaceItem: (id) item {
	id wrapper = [self myEnhancedWebViewForObject];
	if (nil != wrapper) return [wrapper validateUserInterfaceItem: item];
	return [self validateUserInterfaceItem: item];
}
-(void) oldPerformFindPanelAction: (id) sender {
	id wrapper = [self myEnhancedWebViewForObject];
	if (nil != wrapper) {
		[wrapper performFindPanelAction: sender];
	} else {
		[self performFindPanelAction: sender];
	}
}
@end

static void hackWebHTMLView() {
	static dispatch_once_t pred;
	dispatch_once( &pred, ^{
		firstTime = NO;
		Class cls = [WebHTMLView class];
		Method m1 = class_getInstanceMethod(cls, @selector(performFindPanelAction:));
		Method m2 = class_getInstanceMethod(cls, @selector(oldPerformFindPanelAction:));
		method_exchangeImplementations(m1, m2);
		Method m3 = class_getInstanceMethod(cls, @selector(validateUserInterfaceItem:));
		Method m4 = class_getInstanceMethod(cls, @selector(oldValidateUserInterfaceItem:));
		method_exchangeImplementations(m3, m4);
	});
}

Immediately I run into a problem. I need a reference to the WebHTMLView class, but WebHTMLView does NOT appear anywhere in the WebKit Framework headers on my machine. Apple's Open Source site has a header for it. (WebHTMLView is really a subclass of NSControl with a peculiar implementation.) I do not know if I can trust that header to be the same as a production machine however. Without a valid header, GCC will refuse to compile the category. Using a "@class WebHTMLView;" statement is not enough. Let's go deeper.

Instead of a category, implement the interesting methods as static C functions. Store the original method implementations in static variables and call them from the replacement functions. You have to do some explicit casting to reduce the number of warnings. I don't know how to eliminate the 'forward class' warning for WebHTMLView. Use dispatch_once() to ensure the replacement is only done once.

@class WebHTMLView;
static IMP oldPerformFindPanelAction = NULL;
static IMP oldValidateUserInterfaceItem = NULL;

static id enhancedWebViewForObject( id object ) {
	NSResponder *resp;
	for (resp = [object nextResponder]; nil != resp; resp = [resp nextResponder]) {
		if ([resp isKindOfClass: [MyEnhancedWebView class]]) return resp;
		if ([resp isKindOfClass: [WebView class]]) return nil;
	}
	return nil;
}
static id newPerformFindPanelAction(id object, SEL selector, id sender) {
	GestureView *v = enhancedWebViewForObject(object);
	if (nil != v) {
		[v performFindPanelAction: sender];
		return nil;
	}
	return oldPerformFindPanelAction(object,selector,sender);
}
static id newValidateUserInterfaceItem(id object, SEL selector, id item) {
	if ([item action] == @selector(performFindPanelAction:)) {
		MyEnhancedWebView *v = enhancedWebViewForObject(object);
		if (nil != v) return (id)(NSUInteger)[v validateUserInterfaceItem: item];
	}
	return oldValidateUserInterfaceItem(object,selector,item);
}
static void hackWebHTMLView() {
	static dispatch_once_t pred;
	dispatch_once( &pred, ^{
		Class cls = [WebHTMLView class];
		Method m = class_getInstanceMethod(cls, @selector(performFindPanelAction:));
		const char *enc = method_getTypeEncoding(m);
		oldPerformFindPanelAction = method_getImplementation(m);
		class_replaceMethod(cls, @selector(performFindPanelAction:), (IMP) newPerformFindPanelAction, enc);

		m = class_getInstanceMethod(cls, @selector(validateUserInterfaceItem:));
		enc = method_getTypeEncoding(m);
		oldValidateUserInterfaceItem = method_getImplementation(m);
		class_replaceMethod(cls, @selector(validateUserInterfaceItem:), (IMP) newValidateUserInterfaceItem, enc);
	});
}

I have an instance variable in my WebView subclass that refers to my NSDocument subclass. The document implements all of the search behaviors. (They are equally accessible from an NSSearchField and buttons in the document's NIB file.)

- (BOOL)validateUserInterfaceItem:(id < NSValidatedUserInterfaceItem >)anItem {
	if ([anItem action] == @selector(performFindPanelAction:)) return YES;
	return [super validateUserInterfaceItem: anItem];
}

- (void)performFindPanelAction:(id)sender {
	switch ([sender tag]) {
		case NSFindPanelActionShowFindPanel:
			[myDocInstance focusSearchField: self];
			break;
		case NSFindPanelActionNext:
			[myDocInstance searchForward: self];
			break;
		case NSFindPanelActionPrevious:
			[myDocInstance searchBackward: self];
			break;
		case NSFindPanelActionSetFindString:
			[myDocInstance setSearchFieldText: [[self selectedDOMRange] toString]];
			break;
	}
}