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

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

Fix user key equivalents showing in contextual Services menu in 10.4

File size: 18.9 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
[436]155 @try {
[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");
[436]176 } @finally {
[79]177 DisposeHandle(h);
[272]178 [urlString release];
[436]179 }
[183]180
181 return (err == noErr);
[66]182}
183
[74]184// XXX not sure what to do if there's already a selection; BBEdit and MLTE extend it, Tex-Edit Plus doesn't.
185Boolean ICCF_enabled = true;
[66]186
[388]187BOOL ICCF_HandleException(NSException *e, NSEvent *event) {
[74]188 if ([e reason] == nil || [[e reason] length] == 0)
189 return NO;
190
191 if (ICCF_prefs.errorSoundEnabled) NSBeep();
192 if (!ICCF_prefs.errorDialogEnabled) return YES;
193
[388]194 [[NSApplication sharedApplication] activateIgnoringOtherApps: YES];
195 [[event window] makeKeyAndOrderFront: nil];
196
[74]197 int result = NSRunAlertPanel(ICCF_LocalizedString(@"AlertTitle"), ICCF_LocalizedString(@"AlertMessage%@"), nil, nil, ICCF_LocalizedString(@"AlertDisableButton"), e);
[66]198 if (result != NSAlertDefaultReturn) {
[139]199 result = NSRunAlertPanel(ICCF_LocalizedString(@"DisableAlertTitle"), ICCF_LocalizedString(@"DisableAlertMessage%@"), ICCF_LocalizedString(@"DisableAlertDisableButton"), ICCF_LocalizedString(@"DisableAlertDontDisableButton"), nil,
200 [(NSString *)ICCF_CopyAppName() autorelease]);
[66]201 if (result == NSAlertDefaultReturn)
202 ICCF_enabled = NO;
203 }
[74]204 return YES;
[66]205}
206
[183]207void ICCF_LaunchURLFromTextView(NSTextView *self, NSEvent *triggeringEvent) {
[437]208 BOOL isEditable = [self isEditable];
[66]209
[436]210 @try {
[388]211 NSString *s = [[self textStorage] string]; // according to the class documentation, sending 'string' is guaranteed to be O(1)
212 unsigned length = [s length];
[74]213 NSCAssert(s != nil, ICCF_LocalizedString(@"Sorry, ICeCoffEE is unable to locate the insertion point or selection"));
[388]214 NSCAssert(length != 0, ICCF_LocalizedString(@"No text was found"));
[66]215
216 ICCF_StartIC();
217
[388]218 NSRange range = [self selectedRange];
219 NSCAssert(range.location != NSNotFound, ICCF_LocalizedString(@"There is no insertion point or selection in the text field where you clicked"));
[435]220 NSString *url = nil;
[388]221
[435]222 if ([[self textStorage] attribute: NSLinkAttributeName atIndex: range.location
223 effectiveRange: NULL] != nil) {
224 NSRange linkRange;
225 id link = [[self textStorage] attribute: NSLinkAttributeName atIndex: range.location longestEffectiveRange: &linkRange inRange: NSMakeRange(0, length)];
226 if (NSMaxRange(range) <= NSMaxRange(linkRange)) {
227 // selection is entirely within link range
228 url = [link isKindOfClass: [NSURL class]] ? [link absoluteString] : link;
229 range = linkRange;
230 [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO];
231 }
[388]232 }
[435]233 if (url == nil) {
234 if (range.length == 0) {
235 if (range.location == length) range.location--;
236 range.length = 1;
237 range = ICCF_URLEnclosingRange(s, range);
238 [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO];
239 }
240
241 url = [s substringWithRange: range];
242 }
[66]243
[435]244 if (ICCF_LaunchURL(url, ICCF_KeyboardAction(triggeringEvent)) && ICCF_prefs.textBlinkEnabled) {
[437]245 if (isEditable)
246 [self setEditable: NO];
247
[388]248 for (unsigned i = 0 ; i < ICCF_prefs.textBlinkCount ; i++) {
[74]249 NSRange emptyRange = {range.location, 0};
250 [self setSelectedRange: emptyRange affinity: NSSelectionAffinityDownstream stillSelecting: YES];
251 [self display];
252 usleep(kICBlinkDelayUsecs);
253 [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: YES];
254 [self display];
255 usleep(kICBlinkDelayUsecs);
256 }
257 }
[436]258 } @catch (NSException *e) {
259 ICCF_HandleException(e, triggeringEvent);
260 }
[74]261
[66]262 ICCF_StopIC();
[437]263 if (isEditable)
264 [self setEditable: YES];
[66]265}
266
267NSString * const ICCF_SERVICES_ITEM = @"ICeCoffEE Services Item";
268
269NSMenuItem *ICCF_ServicesMenuItem() {
270 NSMenuItem *servicesItem;
[182]271 NSString *servicesTitle = nil;
272 NSMenu *servicesMenu = [NSApp servicesMenu];
273
274 if (servicesMenu != nil) {
275 servicesTitle = [servicesMenu title];
276 if (servicesTitle == nil) {
277 ICLog(@"Can't get service menu title");
278 servicesTitle = @"Services";
279 }
280 } else {
281 servicesTitle = [[NSBundle bundleWithIdentifier: @"com.apple.AppKit"] localizedStringForKey: @"Services" value: nil table: @"ServicesMenu"];
282 if (servicesTitle == nil) {
283 ICLog(@"Can't get localized text for 'Services' in AppKit.framework");
284 servicesTitle = @"Services";
285 }
[66]286 }
287 servicesMenu = [[NSMenu alloc] initWithTitle: servicesTitle];
288 servicesItem = [[NSMenuItem alloc] initWithTitle: servicesTitle action:nil keyEquivalent:@""];
[182]289 ICCF_SetServicesMenu(servicesMenu);
[66]290 [servicesItem setSubmenu: servicesMenu];
291 [servicesItem setRepresentedObject: ICCF_SERVICES_ITEM];
292 [servicesMenu release];
[139]293 return [servicesItem autorelease];
[66]294}
295
[139]296static const unichar UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE = 0x25b8;
297
298// returns YES if menu contains useful items, NO otherwise
[320]299static BOOL ICCF_ConsolidateServicesMenu(NSMenu *menu, NSDictionary *serviceOptions, NSDictionary *serviceInfo) {
[139]300 [menu update]; // doesn't propagate to submenus, so we need to do this first
301 NSEnumerator *enumerator = [[menu itemArray] objectEnumerator];
302 NSMenuItem *menuItem;
303 NSMenu *submenu;
[319]304 NSDictionary *itemOptions = nil, *itemInfo = nil;
[139]305 BOOL shouldKeepItem = NO, shouldKeepMenu = NO;
306
307 while ( (menuItem = [enumerator nextObject]) != nil) {
[142]308 if (serviceOptions != nil)
309 itemOptions = [serviceOptions objectForKey: [menuItem title]];
[319]310 if (serviceInfo != nil)
311 itemInfo = [serviceInfo objectForKey: [menuItem title]];
[142]312 if ([[itemOptions objectForKey: (NSString *)kICServiceHidden] boolValue]) {
313 shouldKeepItem = NO;
314 } else if ( (submenu = [menuItem submenu]) != nil) {
[319]315 // XXX don't rely on nil-sending working
[320]316 shouldKeepItem = ICCF_ConsolidateServicesMenu(submenu, [itemOptions objectForKey: (NSString *)kICServiceSubmenu], [itemInfo objectForKey: (NSString *)kICServiceSubmenu]);
[139]317 if (shouldKeepItem && [submenu numberOfItems] == 1) { // consolidate
318 NSMenuItem *serviceItem = [[submenu itemAtIndex: 0] retain];
319 [serviceItem setTitle:
320 [NSString stringWithFormat: @"%@ %@ %@", [menuItem title], [NSString stringWithCharacters: &UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE length: 1], [serviceItem title]]];
[319]321
[139]322 int serviceIndex = [menu indexOfItem: menuItem];
323 [submenu removeItemAtIndex: 0]; // can't have item in two menus
324 [menu removeItemAtIndex: serviceIndex];
325 [menu insertItem: serviceItem atIndex: serviceIndex];
326 [serviceItem release];
[319]327 menuItem = serviceItem;
[139]328 }
329 } else {
[182]330 [menuItem setKeyEquivalent: @""];
[139]331 shouldKeepItem = [menuItem isEnabled];
332 }
[319]333 if (!shouldKeepItem) {
[139]334 [menu removeItem: menuItem];
[319]335 continue;
[139]336 }
[319]337 shouldKeepMenu = YES;
338
339 if (itemInfo == nil) continue;
340 NSString *bundlePath = (NSString *)[itemInfo objectForKey: (NSString *)kICServiceBundlePath];
341 if (bundlePath == NULL) continue;
342 IconRef serviceIcon = ICCF_CopyIconRefForPath(bundlePath);
343 if (serviceIcon == NULL) continue;
344 [menuItem _setIconRef: serviceIcon];
345 ReleaseIconRef(serviceIcon);
[139]346 }
347
348 return shouldKeepMenu;
349}
350
351NSMenuItem *ICCF_ContextualServicesMenuItem() {
[440]352 // user key equivalents get populated in 10.4, not in 10.5
353 BOOL usesUserKeyEquivalents = [NSMenuItem usesUserKeyEquivalents];
354 if (usesUserKeyEquivalents) {
355 ICCF_SetServicesMenu([NSApp servicesMenu]); // populate menubar menu with key equivalents
356 [NSMenuItem setUsesUserKeyEquivalents: NO];
357 }
358
[139]359 NSMenuItem *servicesItem = ICCF_ServicesMenuItem();
[440]360
361 if (usesUserKeyEquivalents)
362 [NSMenuItem setUsesUserKeyEquivalents: YES];
363
[319]364 NSDictionary *servicesInfo = ICCF_GetServicesInfo(); // XXX cache/retain
365 if (ICCF_ConsolidateServicesMenu([servicesItem submenu], (NSDictionary *)ICCF_prefs.serviceOptions, servicesInfo))
[139]366 return servicesItem;
367 else
368 return nil;
369}
370
[74]371void ICCF_AddRemoveServicesMenu() {
372 // needed because:
373 // (a) we get called before the runloop has properly started and will crash if we don't delay on app startup
374 // (b) the APE message handler calls us from another thread and nothing happens if we try to add a menu on it
375 [ICeCoffEE performSelectorOnMainThread: @selector(IC_addRemoveServicesMenu) withObject: nil waitUntilDone: NO];
[66]376}
377
[167]378NSMenu *ICCF_MenuForEvent(NSView *self, NSMenu *contextMenu, NSEvent *e) {
[66]379 if (contextMenu != nil && [e type] == NSRightMouseDown || ([e type] == NSLeftMouseDown && [e modifierFlags] & NSControlKeyMask)) {
380 int servicesItemIndex = [contextMenu indexOfItemWithRepresentedObject: ICCF_SERVICES_ITEM];
[139]381 // always regenerate: make sure menu reflects context
382 if (servicesItemIndex != -1) {
[74]383 [contextMenu removeItemAtIndex: servicesItemIndex];
384 [contextMenu removeItemAtIndex: servicesItemIndex - 1];
[66]385 }
[139]386 if (ICCF_prefs.servicesInContextualMenu) {
387 NSMenuItem *contextualServicesItem = ICCF_ContextualServicesMenuItem();
388 if (contextualServicesItem != nil) {
389 [contextMenu addItem: [NSMenuItem separatorItem]];
390 [contextMenu addItem: contextualServicesItem];
391 }
392 }
[66]393 }
394 return contextMenu;
395}
396
[183]397static NSEvent *ICCF_MouseDownEventWithModifierFlags(NSEvent *e, BOOL inheritModifierFlags) {
398 return [NSEvent mouseEventWithType: NSLeftMouseDown
399 location: [e locationInWindow]
400 modifierFlags: (inheritModifierFlags ? [e modifierFlags] : 0)
401 timestamp: [e timestamp]
402 windowNumber: [e windowNumber]
403 context: [e context]
404 eventNumber: [e eventNumber]
405 clickCount: 1
406 pressure: 0];
407}
[182]408
[74]409@implementation ICeCoffEE
410
411+ (void)IC_addRemoveServicesMenu;
[66]412{
413 NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu];
[74]414 static NSMenuItem *servicesItem = nil;
415
416 if (servicesItem == nil && ICCF_prefs.servicesInMenuBar) {
417 servicesItem = [ICCF_ServicesMenuItem() retain];
[66]418
[74]419 int insertLoc = [mainMenu indexOfItemWithSubmenu: [NSApp windowsMenu]];
420 if (insertLoc == -1)
421 insertLoc = [mainMenu numberOfItems];
[66]422
[74]423 [mainMenu insertItem: servicesItem atIndex: insertLoc];
424 } else if (servicesItem != nil && !ICCF_prefs.servicesInMenuBar) {
425 [mainMenu removeItem: servicesItem];
426 [servicesItem release];
427 servicesItem = nil;
428 }
[66]429}
430
431// XXX localization?
432- (NSMenu *)menuForEvent:(NSEvent *)e;
433{
434 NSMenu *myMenu = [super menuForEvent: e];
435 return ICCF_MenuForEvent(self, myMenu, e);
436}
437
[435]438static BOOL ICCF_inMouseDown;
439
440- (void)clickedOnLink:(id)link atIndex:(unsigned)charIndex;
441{
442 if (!ICCF_inMouseDown)
443 [super clickedOnLink: link atIndex: charIndex];
444}
445
[388]446- (void)mouseDown:(NSEvent *)downEvent;
[66]447{
448#if ICCF_DEBUG
449 static BOOL down = NO;
450 if (down) {
451 ICLog(@"recursive invocation!");
452 return;
453 }
454 down = YES;
[388]455 ICLog(@"ICeCoffEE down: %@", downEvent);
[66]456#endif
[388]457 [ICeCoffEETrigger cancel];
458
459 if (ICCF_enabled && ICCF_prefs.commandClickEnabled && ICCF_EventIsCommandMouseDown(downEvent)) {
[435]460 ICCF_inMouseDown = YES;
461 @try {
462 [super mouseDown: ICCF_MouseDownEventWithModifierFlags(downEvent, YES)];
463 } @finally {
464 ICCF_inMouseDown = NO;
465 }
[106]466 // we don't actually get a mouseUp event, just wait for mouseDown to return
[66]467 NSEvent *upEvent = [[self window] currentEvent];
[388]468 NSPoint downPt = [downEvent locationInWindow];
[66]469 NSPoint upPt = [upEvent locationInWindow];
470 ICLog(@"next: %@", upEvent);
471 NSAssert([upEvent type] == NSLeftMouseUp, @"NSTextView mouseDown: did not return with current event as mouse up!");
[74]472 if (abs(downPt.x - upPt.x) <= kICHysteresisPixels && abs(downPt.y - upPt.y) <= kICHysteresisPixels) {
[388]473 // make sure we don't have a Command-double-click
474 [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]475 }
[106]476 } else {
[388]477 [super mouseDown: downEvent];
[66]478 }
479#if ICCF_DEBUG
480 down = NO;
481#endif
482}
483
484@end
Note: See TracBrowser for help on using the repository browser.