source: trunk/ICeCoffEE/ICeCoffEE/ICeCoffEE.m@ 327

Last change on this file since 327 was 322, checked in by Nicholas Riley, 17 years ago

ICeCoffEE.[hm]: Move parsing functions (ICCF_CheckRange,
ICCF_Delimiters, ICCF_ParseURL) and Internet Config start/stop
routines (ICCF_Stop/StartIC) to ICeCoffEEParser.[hm] so they can be
tested outside the APE. Also move ICCF_MAX_URL_LEN definition, and
extract guts of NSTextView parsing into ICCF_URLEnclosingRange.
Remove comment about TXNClick; if MLTE is deprecated I'm not going to
mess with it.

ICeCoffEEParser.[hm]: Moved everything discussed above to here.

ICeCoffEEServices.m: Some comments, now I realize how irritating the
service localization problem is.

ICeCoffEETerminal.m: Remove long-unused reference to
ICeCoffEEScanner.h (was from 1.2?).

ICeCoffEEScanner.[hm]: Removed, no longer in use.

TestParser.m: Very simple first pass at testing. There's much more I
want to do here.

urls.plist: First pass at URL test cases.

ICeCoffEE.xcodeproj: Add TestParser target (yes, it uses ZeroLink
because my machine is slow and it actually helps).

File size: 18.2 KB
RevLine 
[182]1// ICeCoffEE - Internet Config Carbon/Cocoa Editor Extension
[66]2// Nicholas Riley <mailto:icecoffee@sabi.net>
3
4#import "ICeCoffEE.h"
5#import <Carbon/Carbon.h>
6#include <unistd.h>
7#import "ICeCoffEESuper.h"
[320]8#import "ICeCoffEEServices.h"
[183]9#import "ICeCoffEETrigger.h"
[322]10#import "ICeCoffEEParser.h"
[66]11
[74]12iccfPrefRec ICCF_prefs;
[66]13
[74]14NSString *ICCF_ErrString(OSStatus err, NSString *context) {
15 if (err == noErr || err == userCanceledErr) return nil;
[66]16
[74]17 NSString *errNum = [NSString stringWithFormat: @"%ld", err];
18 NSString *errDesc = ICCF_LocalizedString(errNum);
[66]19
[74]20 if (errDesc == NULL || errDesc == errNum)
21 errDesc = [NSString stringWithFormat: ICCF_LocalizedString(@"An unknown error occurred in %@"), context];
22
[66]23 return [NSString stringWithFormat: @"%@ (%d)", errDesc, (int)err];
24}
25
[74]26CFStringRef ICCF_CopyErrString(OSStatus err, CFStringRef context) {
27 if (err == noErr || err == userCanceledErr) return NULL;
28
29 CFStringRef errNum = CFStringCreateWithFormat(NULL, NULL, CFSTR("%ld"), err);
30 CFStringRef errDesc = ICCF_CopyLocalizedString(errNum);
31
32 if (errDesc == NULL || errDesc == errNum) {
33 CFStringRef errDescFormat = ICCF_CopyLocalizedString(CFSTR("An unknown error occurred in %@"));
34 if (errDesc != NULL) CFRelease(errDesc);
35 errDesc = CFStringCreateWithFormat(NULL, NULL, errDescFormat, context);
36 }
37
38 CFStringRef errStr = CFStringCreateWithFormat(NULL, NULL, CFSTR("%@ (%d)"), errDesc, (int)err);
39
40 if (errNum != NULL) CFRelease(errNum);
41 if (errDesc != NULL) CFRelease(errDesc);
42 return errStr;
43}
44
[139]45CFStringRef ICCF_CopyAppName() {
46 ProcessSerialNumber psn = {0, kCurrentProcess};
47 CFStringRef appName = NULL;
48 CopyProcessName(&psn, &appName);
49 if (appName == NULL) return CFSTR("(unknown)");
50 return appName;
51}
52
[66]53BOOL ICCF_EventIsCommandMouseDown(NSEvent *e) {
[106]54 return ([e type] == NSLeftMouseDown && ([e modifierFlags] & NSCommandKeyMask) != 0 && [e clickCount] == 1);
[66]55}
56
[183]57iccfURLAction ICCF_KeyboardAction(NSEvent *e) {
58 unsigned int modifierFlags = [e modifierFlags];
[106]59 iccfURLAction action;
60 action.presentMenu = (modifierFlags & NSAlternateKeyMask) != 0;
61 action.launchInBackground = (modifierFlags & NSShiftKeyMask) != 0;
62 return action;
[74]63}
64
[79]65ConstStringPtr ICCF_GetHint(ICInstance inst, const char *urlData, Size length, long *selStart, long *selEnd, Boolean *needsSlashes) {
[74]66 Handle h = NewHandle(0);
67 OSStatus err;
[322]68
[74]69 if (h == NULL) return NULL;
[322]70
[74]71 // parse the URL providing a bogus protocol, to get rid of escaped forms
[79]72 err = ICParseURL(inst, "\p*", urlData, length, selStart, selEnd, h);
[74]73 if (err != noErr) return NULL;
[322]74
[74]75 // scan through the parsed URL looking for characters not found in email addresses
76 Size hSize = GetHandleSize(h);
77 if (hSize == 0) return NULL;
[322]78
[74]79 const char *urlParsed = *h;
80 long i = 0;
81 Boolean sawAt = false;
[79]82 if (hSize >= 2 && urlParsed[0] == '*' && urlParsed[1] == ':') {
[74]83 // this is an IC-inserted protocol; skip over it
84 i = 2;
[79]85 *needsSlashes = (hSize < i + 2 || urlParsed[i] != '/' || urlParsed[i + 1] != '/');
86 } else *needsSlashes = false;
[74]87 for ( ; i < hSize ; i++) {
88 char c = urlParsed[i];
89 if (c == '@') {
90 sawAt = true;
91 } else if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
92 (c == '+' || c == '-' || c == '_' || c == '!' || c == '.'))) {
93 DisposeHandle(h);
94 return "\phttp";
95 }
96 }
97 DisposeHandle(h);
[79]98 if (sawAt) {
99 *needsSlashes = false;
100 return "\pmailto";
101 }
102 return "\phttp";
[74]103}
104
[79]105static const char *kICSlashes = "//";
106
107void ICCF_AddSlashes(Handle h, ConstStringPtr hint) {
108 Size sizeBefore = GetHandleSize(h);
109 unsigned char hintLength = StrLength(hint);
110 char *copy = (char *)malloc(sizeBefore);
111 memcpy(copy, *h, sizeBefore);
112 ICLog(@"ICCF_AddSlashes before: |%s|\n", *h);
113 ReallocateHandle(h, sizeBefore + 2);
[322]114
[79]115 // if *h begins with '<hint>:', then copy the slashes after it
[181]116 if (sizeBefore > hintLength + 1 && strncmp((const char *)&hint[1], copy, hintLength) == 0 && copy[hintLength] == ':') {
[79]117 memcpy(*h, copy, hintLength + 1);
118 memcpy(*h + hintLength + 1, kICSlashes, 2);
119 memcpy(*h + hintLength + 3, &copy[hintLength + 1], sizeBefore - hintLength - 1);
120 } else {
121 memcpy(*h, kICSlashes, 2);
122 memcpy(*h + 2, copy, sizeBefore);
123 }
[322]124
[79]125 free(copy);
126 ICLog(@"ICCF_AddSlashes after: |%s|\n", *h);
127}
128
[183]129BOOL ICCF_LaunchURL(NSString *string, iccfURLAction action) {
130 OSStatus err = noErr;
[66]131 long selStart, selEnd;
[272]132 NSMutableString *urlString = [[NSMutableString alloc] init];
133 NSCharacterSet *whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet];
134 NSScanner *scanner = [[NSScanner alloc] initWithString: string];
135 NSString *fragmentString;
136 while ([scanner scanUpToCharactersFromSet: whitespace intoString: &fragmentString]) {
137 [urlString appendString: fragmentString];
138 }
139 unsigned len = [urlString length];
[66]140
[79]141 Handle h = NULL;
[66]142
143 NS_DURING
[79]144 h = NewHandle(len);
145 if (h == NULL)
146 ICCF_OSErrCAssert(MemError(), @"NewHandle");
[66]147
[272]148 if (CFStringGetBytes((CFStringRef)urlString, CFRangeMake(0, len), kCFStringEncodingASCII, '\0', false, (UInt8 *)*h, len, NULL) != len)
[79]149 ICCF_OSErrCAssert(kTECNoConversionPathErr, @"CFStringGetBytes");
[66]150
151 selStart = 0; selEnd = len;
152
[79]153 Boolean needsSlashes;
154 ConstStringPtr hint = ICCF_GetHint(ICCF_GetInst(), *h, len, &selStart, &selEnd, &needsSlashes);
[167]155 NSCAssert(hint != NULL, @"Internal error: can't get protocol hint for URL");
[74]156
[79]157 if (needsSlashes) {
158 ICCF_AddSlashes(h, hint);
159 len = selEnd = GetHandleSize(h);
160 }
161
[106]162 err = ICCF_DoURLAction(ICCF_GetInst(), hint, *h, selStart, selEnd, action);
163 ICCF_OSErrCAssert(err, @"ICCF_DoURLAction");
[66]164
165 NS_HANDLER
[79]166 DisposeHandle(h);
[272]167 [urlString release];
[66]168 [localException raise];
169 NS_ENDHANDLER
[272]170
[183]171 DisposeHandle(h);
[272]172 [urlString release];
[183]173
174 return (err == noErr);
[66]175}
176
[74]177// XXX not sure what to do if there's already a selection; BBEdit and MLTE extend it, Tex-Edit Plus doesn't.
178Boolean ICCF_enabled = true;
[66]179
[74]180BOOL ICCF_HandleException(NSException *e) {
181 if ([e reason] == nil || [[e reason] length] == 0)
182 return NO;
183
184 if (ICCF_prefs.errorSoundEnabled) NSBeep();
185 if (!ICCF_prefs.errorDialogEnabled) return YES;
186
187 int result = NSRunAlertPanel(ICCF_LocalizedString(@"AlertTitle"), ICCF_LocalizedString(@"AlertMessage%@"), nil, nil, ICCF_LocalizedString(@"AlertDisableButton"), e);
[66]188 if (result != NSAlertDefaultReturn) {
[139]189 result = NSRunAlertPanel(ICCF_LocalizedString(@"DisableAlertTitle"), ICCF_LocalizedString(@"DisableAlertMessage%@"), ICCF_LocalizedString(@"DisableAlertDisableButton"), ICCF_LocalizedString(@"DisableAlertDontDisableButton"), nil,
190 [(NSString *)ICCF_CopyAppName() autorelease]);
[66]191 if (result == NSAlertDefaultReturn)
192 ICCF_enabled = NO;
193 }
[74]194 return YES;
[66]195}
196
[183]197void ICCF_LaunchURLFromTextView(NSTextView *self, NSEvent *triggeringEvent) {
[322]198 NSRange range = [self selectedRange];
[66]199 NSColor *insertionPointColor = [self insertionPointColor];
200 NSString *s = [[self textStorage] string]; // according to the class documentation, sending 'string' is guaranteed to be O(1)
201 int i;
202
203 NS_DURING
204
[74]205 NSCAssert(range.location != NSNotFound, ICCF_LocalizedString(@"There is no insertion point or selection in the text field where you clicked"));
206 NSCAssert(s != nil, ICCF_LocalizedString(@"Sorry, ICeCoffEE is unable to locate the insertion point or selection"));
[66]207
208 ICCF_StartIC();
209
[74]210 NSCAssert([s length] != 0, ICCF_LocalizedString(@"No text was found"));
[66]211
212 if (range.location == [s length]) range.location--; // work around bug in selectionRangeForProposedRange (r. 2845418)
213
[322]214 // XXX is this even worth it to get a starting range? Can just grab back and forth ICCF_MAX_URL_LEN (will need to remove some ICCF_CheckRange calls though)
[66]215 range = [self selectionRangeForProposedRange: range granularity: NSSelectByWord];
216
217 // However, NSSelectByWord does not capture even the approximate boundaries of a URL
218 // (text to a space/line ending character); it'll stop at a period in the middle of a hostname.
219 // So, we expand it as follows:
[320]220
[322]221 range = ICCF_URLEnclosingRange(s, range);
[66]222
223 [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO];
224 [self display];
225
[183]226 if (ICCF_LaunchURL([s substringWithRange: range], ICCF_KeyboardAction(triggeringEvent)) && ICCF_prefs.textBlinkEnabled) {
[74]227 for (i = 0 ; i < ICCF_prefs.textBlinkCount ; i++) {
228 NSRange emptyRange = {range.location, 0};
229 [self setSelectedRange: emptyRange affinity: NSSelectionAffinityDownstream stillSelecting: YES];
230 [self display];
231 usleep(kICBlinkDelayUsecs);
232 [self setInsertionPointColor: [self backgroundColor]];
233 [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: YES];
234 [self display];
235 usleep(kICBlinkDelayUsecs);
236 }
237 }
238
[66]239 NS_HANDLER
240 ICCF_HandleException(localException);
241 NS_ENDHANDLER
242
243 ICCF_StopIC();
244 [self setInsertionPointColor: insertionPointColor];
245}
246
247NSString * const ICCF_SERVICES_ITEM = @"ICeCoffEE Services Item";
248
249NSMenuItem *ICCF_ServicesMenuItem() {
250 NSMenuItem *servicesItem;
[182]251 NSString *servicesTitle = nil;
252 NSMenu *servicesMenu = [NSApp servicesMenu];
253
254 if (servicesMenu != nil) {
255 servicesTitle = [servicesMenu title];
256 if (servicesTitle == nil) {
257 ICLog(@"Can't get service menu title");
258 servicesTitle = @"Services";
259 }
260 } else {
261 servicesTitle = [[NSBundle bundleWithIdentifier: @"com.apple.AppKit"] localizedStringForKey: @"Services" value: nil table: @"ServicesMenu"];
262 if (servicesTitle == nil) {
263 ICLog(@"Can't get localized text for 'Services' in AppKit.framework");
264 servicesTitle = @"Services";
265 }
[66]266 }
267 servicesMenu = [[NSMenu alloc] initWithTitle: servicesTitle];
268 servicesItem = [[NSMenuItem alloc] initWithTitle: servicesTitle action:nil keyEquivalent:@""];
[182]269 ICCF_SetServicesMenu(servicesMenu);
[66]270 [servicesItem setSubmenu: servicesMenu];
271 [servicesItem setRepresentedObject: ICCF_SERVICES_ITEM];
272 [servicesMenu release];
[139]273 return [servicesItem autorelease];
[66]274}
275
[139]276static const unichar UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE = 0x25b8;
277
278// returns YES if menu contains useful items, NO otherwise
[320]279static BOOL ICCF_ConsolidateServicesMenu(NSMenu *menu, NSDictionary *serviceOptions, NSDictionary *serviceInfo) {
[139]280 [menu update]; // doesn't propagate to submenus, so we need to do this first
281 NSEnumerator *enumerator = [[menu itemArray] objectEnumerator];
282 NSMenuItem *menuItem;
283 NSMenu *submenu;
[319]284 NSDictionary *itemOptions = nil, *itemInfo = nil;
[139]285 BOOL shouldKeepItem = NO, shouldKeepMenu = NO;
286
287 while ( (menuItem = [enumerator nextObject]) != nil) {
[142]288 if (serviceOptions != nil)
289 itemOptions = [serviceOptions objectForKey: [menuItem title]];
[319]290 if (serviceInfo != nil)
291 itemInfo = [serviceInfo objectForKey: [menuItem title]];
[142]292 if ([[itemOptions objectForKey: (NSString *)kICServiceHidden] boolValue]) {
293 shouldKeepItem = NO;
294 } else if ( (submenu = [menuItem submenu]) != nil) {
[319]295 // XXX don't rely on nil-sending working
[320]296 shouldKeepItem = ICCF_ConsolidateServicesMenu(submenu, [itemOptions objectForKey: (NSString *)kICServiceSubmenu], [itemInfo objectForKey: (NSString *)kICServiceSubmenu]);
[139]297 if (shouldKeepItem && [submenu numberOfItems] == 1) { // consolidate
298 NSMenuItem *serviceItem = [[submenu itemAtIndex: 0] retain];
299 [serviceItem setTitle:
300 [NSString stringWithFormat: @"%@ %@ %@", [menuItem title], [NSString stringWithCharacters: &UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE length: 1], [serviceItem title]]];
[319]301
[139]302 int serviceIndex = [menu indexOfItem: menuItem];
303 [submenu removeItemAtIndex: 0]; // can't have item in two menus
304 [menu removeItemAtIndex: serviceIndex];
305 [menu insertItem: serviceItem atIndex: serviceIndex];
306 [serviceItem release];
[319]307 menuItem = serviceItem;
[139]308 }
309 } else {
[182]310 [menuItem setKeyEquivalent: @""];
[139]311 shouldKeepItem = [menuItem isEnabled];
312 }
[319]313 if (!shouldKeepItem) {
[139]314 [menu removeItem: menuItem];
[319]315 continue;
[139]316 }
[319]317 shouldKeepMenu = YES;
318
319 if (itemInfo == nil) continue;
320 NSString *bundlePath = (NSString *)[itemInfo objectForKey: (NSString *)kICServiceBundlePath];
321 if (bundlePath == NULL) continue;
322 IconRef serviceIcon = ICCF_CopyIconRefForPath(bundlePath);
323 if (serviceIcon == NULL) continue;
324 [menuItem _setIconRef: serviceIcon];
325 ReleaseIconRef(serviceIcon);
[139]326 }
327
328 return shouldKeepMenu;
329}
330
331NSMenuItem *ICCF_ContextualServicesMenuItem() {
332 NSMenuItem *servicesItem = ICCF_ServicesMenuItem();
[319]333 NSDictionary *servicesInfo = ICCF_GetServicesInfo(); // XXX cache/retain
334 if (ICCF_ConsolidateServicesMenu([servicesItem submenu], (NSDictionary *)ICCF_prefs.serviceOptions, servicesInfo))
[139]335 return servicesItem;
336 else
337 return nil;
338}
339
[74]340void ICCF_AddRemoveServicesMenu() {
341 // needed because:
342 // (a) we get called before the runloop has properly started and will crash if we don't delay on app startup
343 // (b) the APE message handler calls us from another thread and nothing happens if we try to add a menu on it
344 [ICeCoffEE performSelectorOnMainThread: @selector(IC_addRemoveServicesMenu) withObject: nil waitUntilDone: NO];
[66]345}
346
[167]347NSMenu *ICCF_MenuForEvent(NSView *self, NSMenu *contextMenu, NSEvent *e) {
[66]348 if (contextMenu != nil && [e type] == NSRightMouseDown || ([e type] == NSLeftMouseDown && [e modifierFlags] & NSControlKeyMask)) {
349 int servicesItemIndex = [contextMenu indexOfItemWithRepresentedObject: ICCF_SERVICES_ITEM];
[139]350 // always regenerate: make sure menu reflects context
351 if (servicesItemIndex != -1) {
[74]352 [contextMenu removeItemAtIndex: servicesItemIndex];
353 [contextMenu removeItemAtIndex: servicesItemIndex - 1];
[66]354 }
[139]355 if (ICCF_prefs.servicesInContextualMenu) {
356 NSMenuItem *contextualServicesItem = ICCF_ContextualServicesMenuItem();
357 if (contextualServicesItem != nil) {
358 [contextMenu addItem: [NSMenuItem separatorItem]];
359 [contextMenu addItem: contextualServicesItem];
360 }
361 }
[66]362 }
363 return contextMenu;
364}
365
[183]366static NSEvent *ICCF_MouseDownEventWithModifierFlags(NSEvent *e, BOOL inheritModifierFlags) {
367 return [NSEvent mouseEventWithType: NSLeftMouseDown
368 location: [e locationInWindow]
369 modifierFlags: (inheritModifierFlags ? [e modifierFlags] : 0)
370 timestamp: [e timestamp]
371 windowNumber: [e windowNumber]
372 context: [e context]
373 eventNumber: [e eventNumber]
374 clickCount: 1
375 pressure: 0];
376}
[182]377
[183]378
[182]379@interface NSTextView (IC_NSSharing)
380// only in Mac OS X 10.4 and later
381- (NSArray *)selectedRanges;
382@end
383
[74]384@implementation ICeCoffEE
385
386+ (void)IC_addRemoveServicesMenu;
[66]387{
388 NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu];
[74]389 static NSMenuItem *servicesItem = nil;
390
391 if (servicesItem == nil && ICCF_prefs.servicesInMenuBar) {
392 servicesItem = [ICCF_ServicesMenuItem() retain];
[66]393
[74]394 int insertLoc = [mainMenu indexOfItemWithSubmenu: [NSApp windowsMenu]];
395 if (insertLoc == -1)
396 insertLoc = [mainMenu numberOfItems];
[66]397
[74]398 [mainMenu insertItem: servicesItem atIndex: insertLoc];
399 } else if (servicesItem != nil && !ICCF_prefs.servicesInMenuBar) {
400 [mainMenu removeItem: servicesItem];
401 [servicesItem release];
402 servicesItem = nil;
403 }
[183]404 if (floor(NSAppKitVersionNumber) <= NSAppKitVersionNumber10_3) {
405 [[NSApp servicesMenu] update]; // enable keyboard equivalents in Mac OS X 10.3
406 }
[66]407}
408
409// XXX localization?
410- (NSMenu *)menuForEvent:(NSEvent *)e;
411{
412 NSMenu *myMenu = [super menuForEvent: e];
413 return ICCF_MenuForEvent(self, myMenu, e);
414}
415
416- (void)mouseDown:(NSEvent *)e;
417{
418#if ICCF_DEBUG
419 static BOOL down = NO;
420 if (down) {
421 ICLog(@"recursive invocation!");
422 return;
423 }
424 down = YES;
425 ICLog(@"ICeCoffEE down: %@", e);
426#endif
[183]427 if (ICCF_sharedTrigger != nil) {
428 ICLog(@"%@ cancelling", ICCF_sharedTrigger);
429 [ICCF_sharedTrigger cancel];
430 }
[106]431 if (ICCF_enabled && ICCF_prefs.commandClickEnabled && ICCF_EventIsCommandMouseDown(e)) {
[183]432 BOOL inheritModifierFlags;
[182]433 if ([self respondsToSelector: @selector(selectedRanges)]) {
[183]434 // Command-multiple-click or -drag for discontiguous selection, Mac OS X 10.4 or later
435 inheritModifierFlags = YES;
[182]436 } else {
437 // don't want to trigger selection extension or anything else; pass through as a plain click
[183]438 // (on Mac OS X 10.3, command does not modify behavior)
439 inheritModifierFlags = NO;
[182]440 }
[183]441 [super mouseDown: ICCF_MouseDownEventWithModifierFlags(e, inheritModifierFlags)];
[106]442 // we don't actually get a mouseUp event, just wait for mouseDown to return
[66]443 NSEvent *upEvent = [[self window] currentEvent];
444 NSPoint downPt = [e locationInWindow];
445 NSPoint upPt = [upEvent locationInWindow];
446 ICLog(@"next: %@", upEvent);
447 NSAssert([upEvent type] == NSLeftMouseUp, @"NSTextView mouseDown: did not return with current event as mouse up!");
[74]448 if (abs(downPt.x - upPt.x) <= kICHysteresisPixels && abs(downPt.y - upPt.y) <= kICHysteresisPixels) {
[183]449 if (inheritModifierFlags) {
450 // Mac OS X 10.4 and later: make sure we don't have a command-double-click
451 [ICeCoffEETrigger setTriggerForEvent: e onTarget: self]; // gets stored in ICCF_sharedTrigger; the reason for this weird calling pattern is that we don't want to add methods to NSTextView, and we don't want to add a method call on every mouseDown
452 ICLog(@"%@ set", ICCF_sharedTrigger);
453 } else {
454 // Mac OS X 10.3
455 ICCF_LaunchURLFromTextView(self, e);
456 }
[66]457 }
[106]458 } else {
459 [super mouseDown: e];
[66]460 }
461#if ICCF_DEBUG
462 down = NO;
463#endif
464}
465
466@end
Note: See TracBrowser for help on using the repository browser.