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

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

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

File size: 18.6 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    NS_DURING
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       
177    NS_HANDLER
178        DisposeHandle(h);
179        [urlString release];
180        [localException raise];
181    NS_ENDHANDLER
182       
183    DisposeHandle(h);
184    [urlString release];
185
186    return (err == noErr);
187}
188
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;
191
192BOOL ICCF_HandleException(NSException *e, NSEvent *event) {
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   
199    [[NSApplication sharedApplication] activateIgnoringOtherApps: YES];
200    [[event window] makeKeyAndOrderFront: nil];
201   
202    int result = NSRunAlertPanel(ICCF_LocalizedString(@"AlertTitle"), ICCF_LocalizedString(@"AlertMessage%@"), nil, nil, ICCF_LocalizedString(@"AlertDisableButton"), e);
203    if (result != NSAlertDefaultReturn) {
204        result = NSRunAlertPanel(ICCF_LocalizedString(@"DisableAlertTitle"), ICCF_LocalizedString(@"DisableAlertMessage%@"), ICCF_LocalizedString(@"DisableAlertDisableButton"), ICCF_LocalizedString(@"DisableAlertDontDisableButton"), nil,
205           [(NSString *)ICCF_CopyAppName() autorelease]);
206        if (result == NSAlertDefaultReturn)
207            ICCF_enabled = NO;
208    }
209    return YES;
210}
211
212void ICCF_LaunchURLFromTextView(NSTextView *self, NSEvent *triggeringEvent) {
213    NSColor *insertionPointColor = [self insertionPointColor];
214
215    NS_DURING
216
217        NSString *s = [[self textStorage] string]; // according to the class documentation, sending 'string' is guaranteed to be O(1)
218        unsigned length = [s length];
219        NSCAssert(s != nil, ICCF_LocalizedString(@"Sorry, ICeCoffEE is unable to locate the insertion point or selection"));
220        NSCAssert(length != 0, ICCF_LocalizedString(@"No text was found"));
221
222        ICCF_StartIC();
223
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"));
226        NSString *url = nil;
227   
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            }
238        }
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        }
249
250        if (ICCF_LaunchURL(url, ICCF_KeyboardAction(triggeringEvent)) && ICCF_prefs.textBlinkEnabled) {
251            for (unsigned i = 0 ; i < ICCF_prefs.textBlinkCount ; i++) {
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
263    NS_HANDLER
264        ICCF_HandleException(localException, triggeringEvent);
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;
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        }
290    }
291    servicesMenu = [[NSMenu alloc] initWithTitle: servicesTitle];
292    servicesItem = [[NSMenuItem alloc] initWithTitle: servicesTitle action:nil keyEquivalent:@""];
293    ICCF_SetServicesMenu(servicesMenu);
294    [servicesItem setSubmenu: servicesMenu];
295    [servicesItem setRepresentedObject: ICCF_SERVICES_ITEM];
296    [servicesMenu release];
297    return [servicesItem autorelease];
298}
299
300static const unichar UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE = 0x25b8;
301
302// returns YES if menu contains useful items, NO otherwise
303static BOOL ICCF_ConsolidateServicesMenu(NSMenu *menu, NSDictionary *serviceOptions, NSDictionary *serviceInfo) {
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;
308    NSDictionary *itemOptions = nil, *itemInfo = nil;
309    BOOL shouldKeepItem = NO, shouldKeepMenu = NO;
310
311    while ( (menuItem = [enumerator nextObject]) != nil) {
312        if (serviceOptions != nil)
313            itemOptions = [serviceOptions objectForKey: [menuItem title]];
314        if (serviceInfo != nil)
315            itemInfo = [serviceInfo objectForKey: [menuItem title]];
316        if ([[itemOptions objectForKey: (NSString *)kICServiceHidden] boolValue]) {
317            shouldKeepItem = NO;
318        } else if ( (submenu = [menuItem submenu]) != nil) {
319            // XXX don't rely on nil-sending working
320            shouldKeepItem = ICCF_ConsolidateServicesMenu(submenu, [itemOptions objectForKey: (NSString *)kICServiceSubmenu], [itemInfo objectForKey: (NSString *)kICServiceSubmenu]);
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]]];
325               
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];
331                menuItem = serviceItem;
332            }
333        } else {
334            [menuItem setKeyEquivalent: @""];
335            shouldKeepItem = [menuItem isEnabled];
336        }
337        if (!shouldKeepItem) {
338            [menu removeItem: menuItem];
339            continue;
340        }
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);
350    }
351
352    return shouldKeepMenu;
353}
354
355NSMenuItem *ICCF_ContextualServicesMenuItem() {
356    NSMenuItem *servicesItem = ICCF_ServicesMenuItem();
357    NSDictionary *servicesInfo = ICCF_GetServicesInfo(); // XXX cache/retain
358    if (ICCF_ConsolidateServicesMenu([servicesItem submenu], (NSDictionary *)ICCF_prefs.serviceOptions, servicesInfo))
359        return servicesItem;
360    else
361        return nil;
362}
363
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];
369}
370
371NSMenu *ICCF_MenuForEvent(NSView *self, NSMenu *contextMenu, NSEvent *e) {
372    if (contextMenu != nil && [e type] == NSRightMouseDown || ([e type] == NSLeftMouseDown && [e modifierFlags] & NSControlKeyMask)) {
373        int servicesItemIndex = [contextMenu indexOfItemWithRepresentedObject: ICCF_SERVICES_ITEM];
374        // always regenerate: make sure menu reflects context
375        if (servicesItemIndex != -1) {
376            [contextMenu removeItemAtIndex: servicesItemIndex];
377            [contextMenu removeItemAtIndex: servicesItemIndex - 1];
378        }
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        }
386    }
387    return contextMenu;
388}
389
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}
401
402@implementation ICeCoffEE
403
404+ (void)IC_addRemoveServicesMenu;
405{
406    NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu];
407    static NSMenuItem *servicesItem = nil;
408   
409    if (servicesItem == nil && ICCF_prefs.servicesInMenuBar) {
410        servicesItem = [ICCF_ServicesMenuItem() retain];
411
412        int insertLoc = [mainMenu indexOfItemWithSubmenu: [NSApp windowsMenu]];
413        if (insertLoc == -1)
414            insertLoc = [mainMenu numberOfItems];
415
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    }
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
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
439- (void)mouseDown:(NSEvent *)downEvent;
440{
441#if ICCF_DEBUG
442    static BOOL down = NO;
443    if (down) {
444        ICLog(@"recursive invocation!");
445        return;
446    }
447    down = YES;
448    ICLog(@"ICeCoffEE down: %@", downEvent);
449#endif
450    [ICeCoffEETrigger cancel];
451
452    if (ICCF_enabled && ICCF_prefs.commandClickEnabled && ICCF_EventIsCommandMouseDown(downEvent)) {
453        ICCF_inMouseDown = YES;
454        @try {
455            [super mouseDown: ICCF_MouseDownEventWithModifierFlags(downEvent, YES)];
456        } @finally {
457            ICCF_inMouseDown = NO;
458        }
459        // we don't actually get a mouseUp event, just wait for mouseDown to return
460        NSEvent *upEvent = [[self window] currentEvent];
461        NSPoint downPt = [downEvent locationInWindow];
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!");
465        if (abs(downPt.x - upPt.x) <= kICHysteresisPixels && abs(downPt.y - upPt.y) <= kICHysteresisPixels) {
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
468        }
469    } else {
470        [super mouseDown: downEvent];
471    }
472#if ICCF_DEBUG
473    down = NO;
474#endif
475}
476
477@end
Note: See TracBrowser for help on using the repository browser.