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

Last change on this file since 435 was 435, checked in by Nicholas Riley, 16 years ago

Operate on the link target, rather than the text, of NSTextView hyperlinks

File size: 18.6 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"
[388]9#import "ICeCoffEETextViewTrigger.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
[375]65// RFC-ordained max URL length, just to avoid passing IC/LS multi-megabyte documents
66#if ICCF_DEBUG
[381]67const long ICCF_MAX_URL_LEN = 1024; // XXX change later
[375]68#else
[381]69const long ICCF_MAX_URL_LEN = 1024;
[375]70#endif
71
72void ICCF_CheckRange(NSRange range) {
73 NSCAssert(range.length > 0, ICCF_LocalizedString(@"No URL is selected"));
74 NSCAssert1(range.length <= ICCF_MAX_URL_LEN, ICCF_LocalizedString(@"The potential URL is longer than %lu characters"), ICCF_MAX_URL_LEN);
75}
76
[79]77ConstStringPtr ICCF_GetHint(ICInstance inst, const char *urlData, Size length, long *selStart, long *selEnd, Boolean *needsSlashes) {
[74]78 Handle h = NewHandle(0);
79 OSStatus err;
[322]80
[74]81 if (h == NULL) return NULL;
[322]82
[74]83 // parse the URL providing a bogus protocol, to get rid of escaped forms
[79]84 err = ICParseURL(inst, "\p*", urlData, length, selStart, selEnd, h);
[74]85 if (err != noErr) return NULL;
[322]86
[74]87 // scan through the parsed URL looking for characters not found in email addresses
88 Size hSize = GetHandleSize(h);
89 if (hSize == 0) return NULL;
[322]90
[74]91 const char *urlParsed = *h;
92 long i = 0;
93 Boolean sawAt = false;
[79]94 if (hSize >= 2 && urlParsed[0] == '*' && urlParsed[1] == ':') {
[74]95 // this is an IC-inserted protocol; skip over it
96 i = 2;
[79]97 *needsSlashes = (hSize < i + 2 || urlParsed[i] != '/' || urlParsed[i + 1] != '/');
98 } else *needsSlashes = false;
[74]99 for ( ; i < hSize ; i++) {
100 char c = urlParsed[i];
101 if (c == '@') {
102 sawAt = true;
103 } else if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
104 (c == '+' || c == '-' || c == '_' || c == '!' || c == '.'))) {
105 DisposeHandle(h);
106 return "\phttp";
107 }
108 }
109 DisposeHandle(h);
[79]110 if (sawAt) {
111 *needsSlashes = false;
112 return "\pmailto";
113 }
114 return "\phttp";
[74]115}
116
[79]117static const char *kICSlashes = "//";
118
119void ICCF_AddSlashes(Handle h, ConstStringPtr hint) {
120 Size sizeBefore = GetHandleSize(h);
121 unsigned char hintLength = StrLength(hint);
122 char *copy = (char *)malloc(sizeBefore);
123 memcpy(copy, *h, sizeBefore);
124 ICLog(@"ICCF_AddSlashes before: |%s|\n", *h);
125 ReallocateHandle(h, sizeBefore + 2);
[322]126
[79]127 // if *h begins with '<hint>:', then copy the slashes after it
[181]128 if (sizeBefore > hintLength + 1 && strncmp((const char *)&hint[1], copy, hintLength) == 0 && copy[hintLength] == ':') {
[79]129 memcpy(*h, copy, hintLength + 1);
130 memcpy(*h + hintLength + 1, kICSlashes, 2);
131 memcpy(*h + hintLength + 3, &copy[hintLength + 1], sizeBefore - hintLength - 1);
132 } else {
133 memcpy(*h, kICSlashes, 2);
134 memcpy(*h + 2, copy, sizeBefore);
135 }
[322]136
[79]137 free(copy);
138 ICLog(@"ICCF_AddSlashes after: |%s|\n", *h);
139}
140
[183]141BOOL ICCF_LaunchURL(NSString *string, iccfURLAction action) {
142 OSStatus err = noErr;
[66]143 long selStart, selEnd;
[272]144 NSMutableString *urlString = [[NSMutableString alloc] init];
145 NSCharacterSet *whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet];
146 NSScanner *scanner = [[NSScanner alloc] initWithString: string];
147 NSString *fragmentString;
148 while ([scanner scanUpToCharactersFromSet: whitespace intoString: &fragmentString]) {
149 [urlString appendString: fragmentString];
150 }
151 unsigned len = [urlString length];
[66]152
[79]153 Handle h = NULL;
[66]154
155 NS_DURING
[79]156 h = NewHandle(len);
157 if (h == NULL)
158 ICCF_OSErrCAssert(MemError(), @"NewHandle");
[66]159
[272]160 if (CFStringGetBytes((CFStringRef)urlString, CFRangeMake(0, len), kCFStringEncodingASCII, '\0', false, (UInt8 *)*h, len, NULL) != len)
[79]161 ICCF_OSErrCAssert(kTECNoConversionPathErr, @"CFStringGetBytes");
[66]162
163 selStart = 0; selEnd = len;
164
[79]165 Boolean needsSlashes;
166 ConstStringPtr hint = ICCF_GetHint(ICCF_GetInst(), *h, len, &selStart, &selEnd, &needsSlashes);
[167]167 NSCAssert(hint != NULL, @"Internal error: can't get protocol hint for URL");
[74]168
[79]169 if (needsSlashes) {
170 ICCF_AddSlashes(h, hint);
171 len = selEnd = GetHandleSize(h);
172 }
173
[106]174 err = ICCF_DoURLAction(ICCF_GetInst(), hint, *h, selStart, selEnd, action);
175 ICCF_OSErrCAssert(err, @"ICCF_DoURLAction");
[66]176
177 NS_HANDLER
[79]178 DisposeHandle(h);
[272]179 [urlString release];
[66]180 [localException raise];
181 NS_ENDHANDLER
[272]182
[183]183 DisposeHandle(h);
[272]184 [urlString release];
[183]185
186 return (err == noErr);
[66]187}
188
[74]189// XXX not sure what to do if there's already a selection; BBEdit and MLTE extend it, Tex-Edit Plus doesn't.
190Boolean ICCF_enabled = true;
[66]191
[388]192BOOL ICCF_HandleException(NSException *e, NSEvent *event) {
[74]193 if ([e reason] == nil || [[e reason] length] == 0)
194 return NO;
195
196 if (ICCF_prefs.errorSoundEnabled) NSBeep();
197 if (!ICCF_prefs.errorDialogEnabled) return YES;
198
[388]199 [[NSApplication sharedApplication] activateIgnoringOtherApps: YES];
200 [[event window] makeKeyAndOrderFront: nil];
201
[74]202 int result = NSRunAlertPanel(ICCF_LocalizedString(@"AlertTitle"), ICCF_LocalizedString(@"AlertMessage%@"), nil, nil, ICCF_LocalizedString(@"AlertDisableButton"), e);
[66]203 if (result != NSAlertDefaultReturn) {
[139]204 result = NSRunAlertPanel(ICCF_LocalizedString(@"DisableAlertTitle"), ICCF_LocalizedString(@"DisableAlertMessage%@"), ICCF_LocalizedString(@"DisableAlertDisableButton"), ICCF_LocalizedString(@"DisableAlertDontDisableButton"), nil,
205 [(NSString *)ICCF_CopyAppName() autorelease]);
[66]206 if (result == NSAlertDefaultReturn)
207 ICCF_enabled = NO;
208 }
[74]209 return YES;
[66]210}
211
[183]212void ICCF_LaunchURLFromTextView(NSTextView *self, NSEvent *triggeringEvent) {
[66]213 NSColor *insertionPointColor = [self insertionPointColor];
214
215 NS_DURING
216
[388]217 NSString *s = [[self textStorage] string]; // according to the class documentation, sending 'string' is guaranteed to be O(1)
218 unsigned length = [s length];
[74]219 NSCAssert(s != nil, ICCF_LocalizedString(@"Sorry, ICeCoffEE is unable to locate the insertion point or selection"));
[388]220 NSCAssert(length != 0, ICCF_LocalizedString(@"No text was found"));
[66]221
222 ICCF_StartIC();
223
[388]224 NSRange range = [self selectedRange];
225 NSCAssert(range.location != NSNotFound, ICCF_LocalizedString(@"There is no insertion point or selection in the text field where you clicked"));
[435]226 NSString *url = nil;
[388]227
[435]228 if ([[self textStorage] attribute: NSLinkAttributeName atIndex: range.location
229 effectiveRange: NULL] != nil) {
230 NSRange linkRange;
231 id link = [[self textStorage] attribute: NSLinkAttributeName atIndex: range.location longestEffectiveRange: &linkRange inRange: NSMakeRange(0, length)];
232 if (NSMaxRange(range) <= NSMaxRange(linkRange)) {
233 // selection is entirely within link range
234 url = [link isKindOfClass: [NSURL class]] ? [link absoluteString] : link;
235 range = linkRange;
236 [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO];
237 }
[388]238 }
[435]239 if (url == nil) {
240 if (range.length == 0) {
241 if (range.location == length) range.location--;
242 range.length = 1;
243 range = ICCF_URLEnclosingRange(s, range);
244 [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO];
245 }
246
247 url = [s substringWithRange: range];
248 }
[66]249
[435]250 if (ICCF_LaunchURL(url, ICCF_KeyboardAction(triggeringEvent)) && ICCF_prefs.textBlinkEnabled) {
[388]251 for (unsigned i = 0 ; i < ICCF_prefs.textBlinkCount ; i++) {
[74]252 NSRange emptyRange = {range.location, 0};
253 [self setSelectedRange: emptyRange affinity: NSSelectionAffinityDownstream stillSelecting: YES];
254 [self display];
255 usleep(kICBlinkDelayUsecs);
256 [self setInsertionPointColor: [self backgroundColor]];
257 [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: YES];
258 [self display];
259 usleep(kICBlinkDelayUsecs);
260 }
261 }
262
[66]263 NS_HANDLER
[388]264 ICCF_HandleException(localException, triggeringEvent);
[66]265 NS_ENDHANDLER
266
267 ICCF_StopIC();
268 [self setInsertionPointColor: insertionPointColor];
269}
270
271NSString * const ICCF_SERVICES_ITEM = @"ICeCoffEE Services Item";
272
273NSMenuItem *ICCF_ServicesMenuItem() {
274 NSMenuItem *servicesItem;
[182]275 NSString *servicesTitle = nil;
276 NSMenu *servicesMenu = [NSApp servicesMenu];
277
278 if (servicesMenu != nil) {
279 servicesTitle = [servicesMenu title];
280 if (servicesTitle == nil) {
281 ICLog(@"Can't get service menu title");
282 servicesTitle = @"Services";
283 }
284 } else {
285 servicesTitle = [[NSBundle bundleWithIdentifier: @"com.apple.AppKit"] localizedStringForKey: @"Services" value: nil table: @"ServicesMenu"];
286 if (servicesTitle == nil) {
287 ICLog(@"Can't get localized text for 'Services' in AppKit.framework");
288 servicesTitle = @"Services";
289 }
[66]290 }
291 servicesMenu = [[NSMenu alloc] initWithTitle: servicesTitle];
292 servicesItem = [[NSMenuItem alloc] initWithTitle: servicesTitle action:nil keyEquivalent:@""];
[182]293 ICCF_SetServicesMenu(servicesMenu);
[66]294 [servicesItem setSubmenu: servicesMenu];
295 [servicesItem setRepresentedObject: ICCF_SERVICES_ITEM];
296 [servicesMenu release];
[139]297 return [servicesItem autorelease];
[66]298}
299
[139]300static const unichar UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE = 0x25b8;
301
302// returns YES if menu contains useful items, NO otherwise
[320]303static BOOL ICCF_ConsolidateServicesMenu(NSMenu *menu, NSDictionary *serviceOptions, NSDictionary *serviceInfo) {
[139]304 [menu update]; // doesn't propagate to submenus, so we need to do this first
305 NSEnumerator *enumerator = [[menu itemArray] objectEnumerator];
306 NSMenuItem *menuItem;
307 NSMenu *submenu;
[319]308 NSDictionary *itemOptions = nil, *itemInfo = nil;
[139]309 BOOL shouldKeepItem = NO, shouldKeepMenu = NO;
310
311 while ( (menuItem = [enumerator nextObject]) != nil) {
[142]312 if (serviceOptions != nil)
313 itemOptions = [serviceOptions objectForKey: [menuItem title]];
[319]314 if (serviceInfo != nil)
315 itemInfo = [serviceInfo objectForKey: [menuItem title]];
[142]316 if ([[itemOptions objectForKey: (NSString *)kICServiceHidden] boolValue]) {
317 shouldKeepItem = NO;
318 } else if ( (submenu = [menuItem submenu]) != nil) {
[319]319 // XXX don't rely on nil-sending working
[320]320 shouldKeepItem = ICCF_ConsolidateServicesMenu(submenu, [itemOptions objectForKey: (NSString *)kICServiceSubmenu], [itemInfo objectForKey: (NSString *)kICServiceSubmenu]);
[139]321 if (shouldKeepItem && [submenu numberOfItems] == 1) { // consolidate
322 NSMenuItem *serviceItem = [[submenu itemAtIndex: 0] retain];
323 [serviceItem setTitle:
324 [NSString stringWithFormat: @"%@ %@ %@", [menuItem title], [NSString stringWithCharacters: &UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE length: 1], [serviceItem title]]];
[319]325
[139]326 int serviceIndex = [menu indexOfItem: menuItem];
327 [submenu removeItemAtIndex: 0]; // can't have item in two menus
328 [menu removeItemAtIndex: serviceIndex];
329 [menu insertItem: serviceItem atIndex: serviceIndex];
330 [serviceItem release];
[319]331 menuItem = serviceItem;
[139]332 }
333 } else {
[182]334 [menuItem setKeyEquivalent: @""];
[139]335 shouldKeepItem = [menuItem isEnabled];
336 }
[319]337 if (!shouldKeepItem) {
[139]338 [menu removeItem: menuItem];
[319]339 continue;
[139]340 }
[319]341 shouldKeepMenu = YES;
342
343 if (itemInfo == nil) continue;
344 NSString *bundlePath = (NSString *)[itemInfo objectForKey: (NSString *)kICServiceBundlePath];
345 if (bundlePath == NULL) continue;
346 IconRef serviceIcon = ICCF_CopyIconRefForPath(bundlePath);
347 if (serviceIcon == NULL) continue;
348 [menuItem _setIconRef: serviceIcon];
349 ReleaseIconRef(serviceIcon);
[139]350 }
351
352 return shouldKeepMenu;
353}
354
355NSMenuItem *ICCF_ContextualServicesMenuItem() {
356 NSMenuItem *servicesItem = ICCF_ServicesMenuItem();
[319]357 NSDictionary *servicesInfo = ICCF_GetServicesInfo(); // XXX cache/retain
358 if (ICCF_ConsolidateServicesMenu([servicesItem submenu], (NSDictionary *)ICCF_prefs.serviceOptions, servicesInfo))
[139]359 return servicesItem;
360 else
361 return nil;
362}
363
[74]364void ICCF_AddRemoveServicesMenu() {
365 // needed because:
366 // (a) we get called before the runloop has properly started and will crash if we don't delay on app startup
367 // (b) the APE message handler calls us from another thread and nothing happens if we try to add a menu on it
368 [ICeCoffEE performSelectorOnMainThread: @selector(IC_addRemoveServicesMenu) withObject: nil waitUntilDone: NO];
[66]369}
370
[167]371NSMenu *ICCF_MenuForEvent(NSView *self, NSMenu *contextMenu, NSEvent *e) {
[66]372 if (contextMenu != nil && [e type] == NSRightMouseDown || ([e type] == NSLeftMouseDown && [e modifierFlags] & NSControlKeyMask)) {
373 int servicesItemIndex = [contextMenu indexOfItemWithRepresentedObject: ICCF_SERVICES_ITEM];
[139]374 // always regenerate: make sure menu reflects context
375 if (servicesItemIndex != -1) {
[74]376 [contextMenu removeItemAtIndex: servicesItemIndex];
377 [contextMenu removeItemAtIndex: servicesItemIndex - 1];
[66]378 }
[139]379 if (ICCF_prefs.servicesInContextualMenu) {
380 NSMenuItem *contextualServicesItem = ICCF_ContextualServicesMenuItem();
381 if (contextualServicesItem != nil) {
382 [contextMenu addItem: [NSMenuItem separatorItem]];
383 [contextMenu addItem: contextualServicesItem];
384 }
385 }
[66]386 }
387 return contextMenu;
388}
389
[183]390static NSEvent *ICCF_MouseDownEventWithModifierFlags(NSEvent *e, BOOL inheritModifierFlags) {
391 return [NSEvent mouseEventWithType: NSLeftMouseDown
392 location: [e locationInWindow]
393 modifierFlags: (inheritModifierFlags ? [e modifierFlags] : 0)
394 timestamp: [e timestamp]
395 windowNumber: [e windowNumber]
396 context: [e context]
397 eventNumber: [e eventNumber]
398 clickCount: 1
399 pressure: 0];
400}
[182]401
[74]402@implementation ICeCoffEE
403
404+ (void)IC_addRemoveServicesMenu;
[66]405{
406 NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu];
[74]407 static NSMenuItem *servicesItem = nil;
408
409 if (servicesItem == nil && ICCF_prefs.servicesInMenuBar) {
410 servicesItem = [ICCF_ServicesMenuItem() retain];
[66]411
[74]412 int insertLoc = [mainMenu indexOfItemWithSubmenu: [NSApp windowsMenu]];
413 if (insertLoc == -1)
414 insertLoc = [mainMenu numberOfItems];
[66]415
[74]416 [mainMenu insertItem: servicesItem atIndex: insertLoc];
417 } else if (servicesItem != nil && !ICCF_prefs.servicesInMenuBar) {
418 [mainMenu removeItem: servicesItem];
419 [servicesItem release];
420 servicesItem = nil;
421 }
[66]422}
423
424// XXX localization?
425- (NSMenu *)menuForEvent:(NSEvent *)e;
426{
427 NSMenu *myMenu = [super menuForEvent: e];
428 return ICCF_MenuForEvent(self, myMenu, e);
429}
430
[435]431static BOOL ICCF_inMouseDown;
432
433- (void)clickedOnLink:(id)link atIndex:(unsigned)charIndex;
434{
435 if (!ICCF_inMouseDown)
436 [super clickedOnLink: link atIndex: charIndex];
437}
438
[388]439- (void)mouseDown:(NSEvent *)downEvent;
[66]440{
441#if ICCF_DEBUG
442 static BOOL down = NO;
443 if (down) {
444 ICLog(@"recursive invocation!");
445 return;
446 }
447 down = YES;
[388]448 ICLog(@"ICeCoffEE down: %@", downEvent);
[66]449#endif
[388]450 [ICeCoffEETrigger cancel];
451
452 if (ICCF_enabled && ICCF_prefs.commandClickEnabled && ICCF_EventIsCommandMouseDown(downEvent)) {
[435]453 ICCF_inMouseDown = YES;
454 @try {
455 [super mouseDown: ICCF_MouseDownEventWithModifierFlags(downEvent, YES)];
456 } @finally {
457 ICCF_inMouseDown = NO;
458 }
[106]459 // we don't actually get a mouseUp event, just wait for mouseDown to return
[66]460 NSEvent *upEvent = [[self window] currentEvent];
[388]461 NSPoint downPt = [downEvent locationInWindow];
[66]462 NSPoint upPt = [upEvent locationInWindow];
463 ICLog(@"next: %@", upEvent);
464 NSAssert([upEvent type] == NSLeftMouseUp, @"NSTextView mouseDown: did not return with current event as mouse up!");
[74]465 if (abs(downPt.x - upPt.x) <= kICHysteresisPixels && abs(downPt.y - upPt.y) <= kICHysteresisPixels) {
[388]466 // make sure we don't have a Command-double-click
467 [ICeCoffEETextViewTrigger setTriggerForEvent: downEvent 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
[66]468 }
[106]469 } else {
[388]470 [super mouseDown: downEvent];
[66]471 }
472#if ICCF_DEBUG
473 down = NO;
474#endif
475}
476
477@end
Note: See TracBrowser for help on using the repository browser.