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

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

Fix user key equivalents showing in contextual Services menu in 10.4

File size: 18.9 KB
Line 
1// ICeCoffEE - Internet Config Carbon/Cocoa Editor Extension
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"
8#import "ICeCoffEEServices.h"
9#import "ICeCoffEETextViewTrigger.h"
10#import "ICeCoffEEParser.h"
11
12iccfPrefRec ICCF_prefs;
13
14NSString *ICCF_ErrString(OSStatus err, NSString *context) {   
15    if (err == noErr || err == userCanceledErr) return nil;
16
17    NSString *errNum = [NSString stringWithFormat: @"%ld", err];
18    NSString *errDesc = ICCF_LocalizedString(errNum);
19
20    if (errDesc == NULL || errDesc == errNum)
21        errDesc = [NSString stringWithFormat: ICCF_LocalizedString(@"An unknown error occurred in %@"), context];
22
23    return [NSString stringWithFormat: @"%@ (%d)", errDesc, (int)err];
24}
25
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
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
53BOOL ICCF_EventIsCommandMouseDown(NSEvent *e) {
54    return ([e type] == NSLeftMouseDown && ([e modifierFlags] & NSCommandKeyMask) != 0 && [e clickCount] == 1);
55}
56
57iccfURLAction ICCF_KeyboardAction(NSEvent *e) {
58    unsigned int modifierFlags = [e modifierFlags];
59    iccfURLAction action;
60    action.presentMenu = (modifierFlags & NSAlternateKeyMask) != 0;
61    action.launchInBackground = (modifierFlags & NSShiftKeyMask) != 0;
62    return action;
63}
64
65// RFC-ordained max URL length, just to avoid passing IC/LS multi-megabyte documents
66#if ICCF_DEBUG
67const long ICCF_MAX_URL_LEN = 1024; // XXX change later
68#else
69const long ICCF_MAX_URL_LEN = 1024;
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
77ConstStringPtr ICCF_GetHint(ICInstance inst, const char *urlData, Size length, long *selStart, long *selEnd, Boolean *needsSlashes) {
78    Handle h = NewHandle(0);
79    OSStatus err;
80   
81    if (h == NULL) return NULL;
82   
83    // parse the URL providing a bogus protocol, to get rid of escaped forms
84    err = ICParseURL(inst, "\p*", urlData, length, selStart, selEnd, h);
85    if (err != noErr) return NULL;
86   
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;
90   
91    const char *urlParsed = *h;
92    long i = 0;
93    Boolean sawAt = false;
94    if (hSize >= 2 && urlParsed[0] == '*' && urlParsed[1] == ':') {
95        // this is an IC-inserted protocol; skip over it
96        i = 2;
97        *needsSlashes = (hSize < i + 2 || urlParsed[i] != '/' || urlParsed[i + 1] != '/');
98    } else *needsSlashes = false;
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);
110    if (sawAt) {
111        *needsSlashes = false;
112        return "\pmailto";
113    }
114    return "\phttp";
115}
116
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);
126   
127    // if *h begins with '<hint>:', then copy the slashes after it
128    if (sizeBefore > hintLength + 1 && strncmp((const char *)&hint[1], copy, hintLength) == 0 && copy[hintLength] == ':') {
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    }
136   
137    free(copy);
138    ICLog(@"ICCF_AddSlashes after: |%s|\n", *h);
139}
140
141BOOL ICCF_LaunchURL(NSString *string, iccfURLAction action) {
142    OSStatus err = noErr;
143    long selStart, selEnd;
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];
152
153    Handle h = NULL;
154   
155    @try {
156        h = NewHandle(len);
157        if (h == NULL)
158            ICCF_OSErrCAssert(MemError(), @"NewHandle");
159
160        if (CFStringGetBytes((CFStringRef)urlString, CFRangeMake(0, len), kCFStringEncodingASCII, '\0', false, (UInt8 *)*h, len, NULL) != len)
161            ICCF_OSErrCAssert(kTECNoConversionPathErr, @"CFStringGetBytes");
162
163        selStart = 0; selEnd = len;
164
165        Boolean needsSlashes;
166        ConstStringPtr hint = ICCF_GetHint(ICCF_GetInst(), *h, len, &selStart, &selEnd, &needsSlashes);
167        NSCAssert(hint != NULL, @"Internal error: can't get protocol hint for URL");
168
169        if (needsSlashes) {
170            ICCF_AddSlashes(h, hint);
171            len = selEnd = GetHandleSize(h);
172        }
173
174        err = ICCF_DoURLAction(ICCF_GetInst(), hint, *h, selStart, selEnd, action);
175        ICCF_OSErrCAssert(err, @"ICCF_DoURLAction");
176    } @finally {
177        DisposeHandle(h);
178        [urlString release];
179    }
180
181    return (err == noErr);
182}
183
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;
186
187BOOL ICCF_HandleException(NSException *e, NSEvent *event) {
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   
194    [[NSApplication sharedApplication] activateIgnoringOtherApps: YES];
195    [[event window] makeKeyAndOrderFront: nil];
196   
197    int result = NSRunAlertPanel(ICCF_LocalizedString(@"AlertTitle"), ICCF_LocalizedString(@"AlertMessage%@"), nil, nil, ICCF_LocalizedString(@"AlertDisableButton"), e);
198    if (result != NSAlertDefaultReturn) {
199        result = NSRunAlertPanel(ICCF_LocalizedString(@"DisableAlertTitle"), ICCF_LocalizedString(@"DisableAlertMessage%@"), ICCF_LocalizedString(@"DisableAlertDisableButton"), ICCF_LocalizedString(@"DisableAlertDontDisableButton"), nil,
200           [(NSString *)ICCF_CopyAppName() autorelease]);
201        if (result == NSAlertDefaultReturn)
202            ICCF_enabled = NO;
203    }
204    return YES;
205}
206
207void ICCF_LaunchURLFromTextView(NSTextView *self, NSEvent *triggeringEvent) {
208    BOOL isEditable = [self isEditable];
209
210    @try {
211        NSString *s = [[self textStorage] string]; // according to the class documentation, sending 'string' is guaranteed to be O(1)
212        unsigned length = [s length];
213        NSCAssert(s != nil, ICCF_LocalizedString(@"Sorry, ICeCoffEE is unable to locate the insertion point or selection"));
214        NSCAssert(length != 0, ICCF_LocalizedString(@"No text was found"));
215
216        ICCF_StartIC();
217
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"));
220        NSString *url = nil;
221   
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            }
232        }
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        }
243
244        if (ICCF_LaunchURL(url, ICCF_KeyboardAction(triggeringEvent)) && ICCF_prefs.textBlinkEnabled) {
245            if (isEditable)
246                [self setEditable: NO];
247
248            for (unsigned i = 0 ; i < ICCF_prefs.textBlinkCount ; i++) {
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        }
258    } @catch (NSException *e) {
259        ICCF_HandleException(e, triggeringEvent);
260    }
261
262    ICCF_StopIC();
263    if (isEditable)
264        [self setEditable: YES];
265}
266
267NSString * const ICCF_SERVICES_ITEM = @"ICeCoffEE Services Item";
268
269NSMenuItem *ICCF_ServicesMenuItem() {
270    NSMenuItem *servicesItem;
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        }
286    }
287    servicesMenu = [[NSMenu alloc] initWithTitle: servicesTitle];
288    servicesItem = [[NSMenuItem alloc] initWithTitle: servicesTitle action:nil keyEquivalent:@""];
289    ICCF_SetServicesMenu(servicesMenu);
290    [servicesItem setSubmenu: servicesMenu];
291    [servicesItem setRepresentedObject: ICCF_SERVICES_ITEM];
292    [servicesMenu release];
293    return [servicesItem autorelease];
294}
295
296static const unichar UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE = 0x25b8;
297
298// returns YES if menu contains useful items, NO otherwise
299static BOOL ICCF_ConsolidateServicesMenu(NSMenu *menu, NSDictionary *serviceOptions, NSDictionary *serviceInfo) {
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;
304    NSDictionary *itemOptions = nil, *itemInfo = nil;
305    BOOL shouldKeepItem = NO, shouldKeepMenu = NO;
306
307    while ( (menuItem = [enumerator nextObject]) != nil) {
308        if (serviceOptions != nil)
309            itemOptions = [serviceOptions objectForKey: [menuItem title]];
310        if (serviceInfo != nil)
311            itemInfo = [serviceInfo objectForKey: [menuItem title]];
312        if ([[itemOptions objectForKey: (NSString *)kICServiceHidden] boolValue]) {
313            shouldKeepItem = NO;
314        } else if ( (submenu = [menuItem submenu]) != nil) {
315            // XXX don't rely on nil-sending working
316            shouldKeepItem = ICCF_ConsolidateServicesMenu(submenu, [itemOptions objectForKey: (NSString *)kICServiceSubmenu], [itemInfo objectForKey: (NSString *)kICServiceSubmenu]);
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]]];
321               
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];
327                menuItem = serviceItem;
328            }
329        } else {
330            [menuItem setKeyEquivalent: @""];
331            shouldKeepItem = [menuItem isEnabled];
332        }
333        if (!shouldKeepItem) {
334            [menu removeItem: menuItem];
335            continue;
336        }
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);
346    }
347
348    return shouldKeepMenu;
349}
350
351NSMenuItem *ICCF_ContextualServicesMenuItem() {
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   
359    NSMenuItem *servicesItem = ICCF_ServicesMenuItem();
360
361    if (usesUserKeyEquivalents)
362        [NSMenuItem setUsesUserKeyEquivalents: YES];
363
364    NSDictionary *servicesInfo = ICCF_GetServicesInfo(); // XXX cache/retain
365    if (ICCF_ConsolidateServicesMenu([servicesItem submenu], (NSDictionary *)ICCF_prefs.serviceOptions, servicesInfo))
366        return servicesItem;
367    else
368        return nil;
369}
370
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];
376}
377
378NSMenu *ICCF_MenuForEvent(NSView *self, NSMenu *contextMenu, NSEvent *e) {
379    if (contextMenu != nil && [e type] == NSRightMouseDown || ([e type] == NSLeftMouseDown && [e modifierFlags] & NSControlKeyMask)) {
380        int servicesItemIndex = [contextMenu indexOfItemWithRepresentedObject: ICCF_SERVICES_ITEM];
381        // always regenerate: make sure menu reflects context
382        if (servicesItemIndex != -1) {
383            [contextMenu removeItemAtIndex: servicesItemIndex];
384            [contextMenu removeItemAtIndex: servicesItemIndex - 1];
385        }
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        }
393    }
394    return contextMenu;
395}
396
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}
408
409@implementation ICeCoffEE
410
411+ (void)IC_addRemoveServicesMenu;
412{
413    NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu];
414    static NSMenuItem *servicesItem = nil;
415   
416    if (servicesItem == nil && ICCF_prefs.servicesInMenuBar) {
417        servicesItem = [ICCF_ServicesMenuItem() retain];
418
419        int insertLoc = [mainMenu indexOfItemWithSubmenu: [NSApp windowsMenu]];
420        if (insertLoc == -1)
421            insertLoc = [mainMenu numberOfItems];
422
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    }
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
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
446- (void)mouseDown:(NSEvent *)downEvent;
447{
448#if ICCF_DEBUG
449    static BOOL down = NO;
450    if (down) {
451        ICLog(@"recursive invocation!");
452        return;
453    }
454    down = YES;
455    ICLog(@"ICeCoffEE down: %@", downEvent);
456#endif
457    [ICeCoffEETrigger cancel];
458
459    if (ICCF_enabled && ICCF_prefs.commandClickEnabled && ICCF_EventIsCommandMouseDown(downEvent)) {
460        ICCF_inMouseDown = YES;
461        @try {
462            [super mouseDown: ICCF_MouseDownEventWithModifierFlags(downEvent, YES)];
463        } @finally {
464            ICCF_inMouseDown = NO;
465        }
466        // we don't actually get a mouseUp event, just wait for mouseDown to return
467        NSEvent *upEvent = [[self window] currentEvent];
468        NSPoint downPt = [downEvent locationInWindow];
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!");
472        if (abs(downPt.x - upPt.x) <= kICHysteresisPixels && abs(downPt.y - upPt.y) <= kICHysteresisPixels) {
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
475        }
476    } else {
477        [super mouseDown: downEvent];
478    }
479#if ICCF_DEBUG
480    down = NO;
481#endif
482}
483
484@end
Note: See TracBrowser for help on using the repository browser.