2005-05-09

Integrating with Mac OS 10.4's dictionary from Java

Some time ago I wrote a little program to pretend to be ispell(1) but talk to NSSpellChecker so you could use Mac OS' native spelling checker. This let me imitate NSTextView's check-as-you-type in JTextArea, and in home-grown text components too.

Now Mac OS 10.4 offers us a dictionary and thesaurus. And I want them in my Java applications. There are three different interfaces, which I've already complained about here. The dashboard isn't important; I just don't see the point. The other two, the full-blown application and the little pop-up window that appears around the word in question, though: they're important and useful.

NSTextView and WebView both have new "Look Up in Dictionary" menu items, and there's a control-command-D keystroke. The menus have "Search in Spotlight" and "Search in Google" too, but they're trivial to implement in Java. "Look Up in Dictionary" isn't too hard, either, because you can use a dict: URI like this [definition; that link for Mac OS only]. The only slight bummer if you're using this is that if Dictionary.app isn't already running, your first open(1) or whatever will open a window that says "Type a word to look up in the dictionary and thesaurus... // Oxford American Dictionaries", and you'll need to open(1) again to actually look up the word. If Dictionary.app was already running, that will cause annoying flicker, because it's not smart enough to notice that it's already showing that word.

You can see Dictionary.app register that it handles these URIs if you look for CFBundleURLTypes in its Info.plist file. (John Gruber already mentioned this in Dictionary Look-Ups From BBEdit, Mailsmith, and TextWrangler, with more detail if you follow the link to his "Tiger Details report".)

In the same file, if you look for NSServices and you'll see it register a system service. Strangely, this code doesn't seem to work, though the commented-out line does:

NSArray* types = [NSArray arrayWithObject:NSStringPboardType];
NSPasteboard* pb = [NSPasteboard pasteboardWithUniqueName];
[pb declareTypes:types owner:nil];
[pb setString:@"blackberry" forType:NSStringPboardType];
BOOL success = NSPerformService(@"Look Up in Dictionary", pb);
//BOOL success = NSPerformService(@"Mail/Send To", pb);

But then I have very little Cocoa experience, sadly, so there could be some obvious idiocy there on my part.

The control-command-D keystroke is what's giving me headaches. If you're using AWT's TextArea, you're home free because it's an NSTextView, and that just works. JTextArea, though, doesn't work, and I'm not sure why not.

Here's what I've found out so far...

There are new private methods in AppKit:

93a24d20 t -[NSTextView _lookUpDefiniteRangeInDictionaryFromMenu:]
93a24d8c t -[NSTextView _lookUpIndefiniteRangeInDictionaryFromMenu:]
93a24858 t -[NSTextView _lookUpRangeInDictionary:]

But StandardKeyBinding.dict doesn't have a binding. If you run TextEdit in gdb(1) with suitable breakpoints, it looks like these methods are actually just responsible for the menu item:

(gdb) bt
#0 0x93a2486c in -[NSTextView _lookUpRangeInDictionary:] ()
#1 0x93a24d78 in -[NSTextView _lookUpDefiniteRangeInDictionaryFromMenu:] ()
#2 0x936bd274 in -[NSApplication sendAction:to:from:] ()
#3 0x93717a70 in -[NSMenu performActionForItemAtIndex:] ()
#4 0x937177f4 in -[NSCarbonMenuImpl performActionWithHighlightingForItemAtIndex:] ()
#5 0x937402fc in _NSPopUpCarbonMenu2 ()
#6 0x9373f93c in _NSPopUpCarbonMenu1 ()
#7 0x93797110 in -[NSCarbonMenuImpl _popUpContextMenu:withEvent:forView:withFont:] ()
#8 0x93796f90 in -[NSMenu _popUpContextMenu:withEvent:forView:withFont:] ()
#9 0x93677ec4 in -[NSWindow sendEvent:] ()
#10 0x9362113c in -[NSApplication sendEvent:] ()
#11 0x936185d0 in -[NSApplication run] ()
#12 0x93708e04 in NSApplicationMain ()
#13 0x000020e8 in ?? ()
#14 0x0000b298 in ?? ()

One clue was that the first time I tried a JTextArea, hitting control-command-D actually caused the program to freeze. Activity Monitor showed this:

Analysis of sampling pid 16781 every 10.000000 milliseconds
Call graph:
277 Thread_100f
277 0x1aec
277 0x1c4c
277 0x4f64
277 CFRunLoopRunSpecific
277 __CFRunLoopRun
277 __CFRunLoopDoSources0
277 __CFRunLoopPerformPerform
277 __NSFireMainThreadPerform
277 +[AWTStarter startAWT:]
277 -[NSApplication run]
277 -[NSApplication
nextEventMatchingMask:untilDate:inMode:dequeue:]
277 _DPSNextEvent
277 BlockUntilNextEventMatchingListInMode
277 ReceiveNextEventCommon
277 RunCurrentEventLoopInMode
277 CFRunLoopRunSpecific
277 __CFRunLoopRun
277 __CFRunLoopDoSource1
277 __CFMessagePortPerform
277 DSInitializeMessageReceiving
277 DSGetTextAttributes
277 DSAXGetTextAttributes
277 DSAXGetTextAttributes
277 objc_msgSend
277 objc_msgSend

Where "AX" is an Apple abbreviation (in lieu of namespaces/packages) for accessibility. I don't know what the "DS" is about.

There's a PopupDictDaemon.app tucked away inside Dictionary.app and sure enough, an nm(1) of the PopupDictDaemon shows lots of calls to accessibility functions, but also to a variety of Core Graphics' private functions (beginning CGS), including CGSSetSymbolicHotKey, which is presumably responsible for registering the keystroke. It probably then does a CGSFindWindowAndOwner and then uses accessibility functions such as AXUIElementCopyAttributeValue to find the word in question, its location, and its font. There are a bunch of calls to functions beginning DCM that appear to do the work of dictionary lookup, and then there's just the views you can see in its nib file to configure.

So why doesn't JTextArea work? I need to know that so I can make my own text components work. (And notice that Terminal doesn't work, either.) If you run Accessibility Inspector, and remember what I said about all the code in PopupDictDaemon to find the details of how the word in question looks (because the pop-up draws the word over itself with a different background, rather than just leaving the word as it is and popping up the definition below it), it starts to look clear: NSTextView offers the parameterized attributes AXRTFForRange, AXStyleRangeForIndex, and AXAttributedStringForRange in addition to those offered by JTextArea. Terminal.app also just offers JTextArea's basic style-less set. javax.accessibility.AccessibleText has a getCharacterAttribute method, which you'd imagine could be used to knock up the missing accessibility attributes, but perhaps there's some reason why this isn't happening. This is the first time I've so much as looked at the accessibility API. (I can't help but admire this as a cunning ploy by Apple to make developers and able users care about accessibility.)

By the way, if you're thinking you can fall back to handling the command-control-D keystroke with a call to open(1) with a dict: URI, you'd be wrong: PopupDictDaemon swallows the keystroke even if it does nothing with it. You never get to see it.