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

Last change on this file since 322 was 322, checked in by Nicholas Riley, 13 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
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 "ICeCoffEETrigger.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
65ConstStringPtr ICCF_GetHint(ICInstance inst, const char *urlData, Size length, long *selStart, long *selEnd, Boolean *needsSlashes) {
66    Handle h = NewHandle(0);
67    OSStatus err;
68   
69    if (h == NULL) return NULL;
70   
71    // parse the URL providing a bogus protocol, to get rid of escaped forms
72    err = ICParseURL(inst, "\p*", urlData, length, selStart, selEnd, h);
73    if (err != noErr) return NULL;
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;
78   
79    const char *urlParsed = *h;
80    long i = 0;
81    Boolean sawAt = false;
82    if (hSize >= 2 && urlParsed[0] == '*' && urlParsed[1] == ':') {
83        // this is an IC-inserted protocol; skip over it
84        i = 2;
85        *needsSlashes = (hSize < i + 2 || urlParsed[i] != '/' || urlParsed[i + 1] != '/');
86    } else *needsSlashes = false;
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);
98    if (sawAt) {
99        *needsSlashes = false;
100        return "\pmailto";
101    }
102    return "\phttp";
103}
104
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);
114   
115    // if *h begins with '<hint>:', then copy the slashes after it
116    if (sizeBefore > hintLength + 1 && strncmp((const char *)&hint[1], copy, hintLength) == 0 && copy[hintLength] == ':') {
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    }
124   
125    free(copy);
126    ICLog(@"ICCF_AddSlashes after: |%s|\n", *h);
127}
128
129BOOL ICCF_LaunchURL(NSString *string, iccfURLAction action) {
130    OSStatus err = noErr;
131    long selStart, selEnd;
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];
140
141    Handle h = NULL;
142   
143    NS_DURING
144        h = NewHandle(len);
145        if (h == NULL)
146            ICCF_OSErrCAssert(MemError(), @"NewHandle");
147
148        if (CFStringGetBytes((CFStringRef)urlString, CFRangeMake(0, len), kCFStringEncodingASCII, '\0', false, (UInt8 *)*h, len, NULL) != len)
149            ICCF_OSErrCAssert(kTECNoConversionPathErr, @"CFStringGetBytes");
150
151        selStart = 0; selEnd = len;
152
153        Boolean needsSlashes;
154        ConstStringPtr hint = ICCF_GetHint(ICCF_GetInst(), *h, len, &selStart, &selEnd, &needsSlashes);
155        NSCAssert(hint != NULL, @"Internal error: can't get protocol hint for URL");
156
157        if (needsSlashes) {
158            ICCF_AddSlashes(h, hint);
159            len = selEnd = GetHandleSize(h);
160        }
161
162        err = ICCF_DoURLAction(ICCF_GetInst(), hint, *h, selStart, selEnd, action);
163        ICCF_OSErrCAssert(err, @"ICCF_DoURLAction");
164       
165    NS_HANDLER
166        DisposeHandle(h);
167        [urlString release];
168        [localException raise];
169    NS_ENDHANDLER
170       
171    DisposeHandle(h);
172    [urlString release];
173
174    return (err == noErr);
175}
176
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;
179
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);
188    if (result != NSAlertDefaultReturn) {
189        result = NSRunAlertPanel(ICCF_LocalizedString(@"DisableAlertTitle"), ICCF_LocalizedString(@"DisableAlertMessage%@"), ICCF_LocalizedString(@"DisableAlertDisableButton"), ICCF_LocalizedString(@"DisableAlertDontDisableButton"), nil,
190           [(NSString *)ICCF_CopyAppName() autorelease]);
191        if (result == NSAlertDefaultReturn)
192            ICCF_enabled = NO;
193    }
194    return YES;
195}
196
197void ICCF_LaunchURLFromTextView(NSTextView *self, NSEvent *triggeringEvent) {
198    NSRange range = [self selectedRange];
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
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"));
207
208        ICCF_StartIC();
209
210        NSCAssert([s length] != 0, ICCF_LocalizedString(@"No text was found"));
211
212        if (range.location == [s length]) range.location--; // work around bug in selectionRangeForProposedRange (r. 2845418)
213
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)
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:
220       
221        range = ICCF_URLEnclosingRange(s, range);
222
223        [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO];
224        [self display];
225
226        if (ICCF_LaunchURL([s substringWithRange: range], ICCF_KeyboardAction(triggeringEvent)) && ICCF_prefs.textBlinkEnabled) {
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
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;
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        }
266    }
267    servicesMenu = [[NSMenu alloc] initWithTitle: servicesTitle];
268    servicesItem = [[NSMenuItem alloc] initWithTitle: servicesTitle action:nil keyEquivalent:@""];
269    ICCF_SetServicesMenu(servicesMenu);
270    [servicesItem setSubmenu: servicesMenu];
271    [servicesItem setRepresentedObject: ICCF_SERVICES_ITEM];
272    [servicesMenu release];
273    return [servicesItem autorelease];
274}
275
276static const unichar UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE = 0x25b8;
277
278// returns YES if menu contains useful items, NO otherwise
279static BOOL ICCF_ConsolidateServicesMenu(NSMenu *menu, NSDictionary *serviceOptions, NSDictionary *serviceInfo) {
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;
284    NSDictionary *itemOptions = nil, *itemInfo = nil;
285    BOOL shouldKeepItem = NO, shouldKeepMenu = NO;
286
287    while ( (menuItem = [enumerator nextObject]) != nil) {
288        if (serviceOptions != nil)
289            itemOptions = [serviceOptions objectForKey: [menuItem title]];
290        if (serviceInfo != nil)
291            itemInfo = [serviceInfo objectForKey: [menuItem title]];
292        if ([[itemOptions objectForKey: (NSString *)kICServiceHidden] boolValue]) {
293            shouldKeepItem = NO;
294        } else if ( (submenu = [menuItem submenu]) != nil) {
295            // XXX don't rely on nil-sending working
296            shouldKeepItem = ICCF_ConsolidateServicesMenu(submenu, [itemOptions objectForKey: (NSString *)kICServiceSubmenu], [itemInfo objectForKey: (NSString *)kICServiceSubmenu]);
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]]];
301               
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];
307                menuItem = serviceItem;
308            }
309        } else {
310            [menuItem setKeyEquivalent: @""];
311            shouldKeepItem = [menuItem isEnabled];
312        }
313        if (!shouldKeepItem) {
314            [menu removeItem: menuItem];
315            continue;
316        }
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);
326    }
327
328    return shouldKeepMenu;
329}
330
331NSMenuItem *ICCF_ContextualServicesMenuItem() {
332    NSMenuItem *servicesItem = ICCF_ServicesMenuItem();
333    NSDictionary *servicesInfo = ICCF_GetServicesInfo(); // XXX cache/retain
334    if (ICCF_ConsolidateServicesMenu([servicesItem submenu], (NSDictionary *)ICCF_prefs.serviceOptions, servicesInfo))
335        return servicesItem;
336    else
337        return nil;
338}
339
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];
345}
346
347NSMenu *ICCF_MenuForEvent(NSView *self, NSMenu *contextMenu, NSEvent *e) {
348    if (contextMenu != nil && [e type] == NSRightMouseDown || ([e type] == NSLeftMouseDown && [e modifierFlags] & NSControlKeyMask)) {
349        int servicesItemIndex = [contextMenu indexOfItemWithRepresentedObject: ICCF_SERVICES_ITEM];
350        // always regenerate: make sure menu reflects context
351        if (servicesItemIndex != -1) {
352            [contextMenu removeItemAtIndex: servicesItemIndex];
353            [contextMenu removeItemAtIndex: servicesItemIndex - 1];
354        }
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        }
362    }
363    return contextMenu;
364}
365
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}
377
378
379@interface NSTextView (IC_NSSharing)
380// only in Mac OS X 10.4 and later
381- (NSArray *)selectedRanges;
382@end
383
384@implementation ICeCoffEE
385
386+ (void)IC_addRemoveServicesMenu;
387{
388    NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu];
389    static NSMenuItem *servicesItem = nil;
390   
391    if (servicesItem == nil && ICCF_prefs.servicesInMenuBar) {
392        servicesItem = [ICCF_ServicesMenuItem() retain];
393
394        int insertLoc = [mainMenu indexOfItemWithSubmenu: [NSApp windowsMenu]];
395        if (insertLoc == -1)
396            insertLoc = [mainMenu numberOfItems];
397
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    }
404    if (floor(NSAppKitVersionNumber) <= NSAppKitVersionNumber10_3) {
405        [[NSApp servicesMenu] update]; // enable keyboard equivalents in Mac OS X 10.3
406    }
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
427    if (ICCF_sharedTrigger != nil) {
428        ICLog(@"%@ cancelling", ICCF_sharedTrigger);
429        [ICCF_sharedTrigger cancel];
430    }
431    if (ICCF_enabled && ICCF_prefs.commandClickEnabled && ICCF_EventIsCommandMouseDown(e)) {
432        BOOL inheritModifierFlags;
433        if ([self respondsToSelector: @selector(selectedRanges)]) {
434            // Command-multiple-click or -drag for discontiguous selection, Mac OS X 10.4 or later
435            inheritModifierFlags = YES;
436        } else {
437            // don't want to trigger selection extension or anything else; pass through as a plain click
438            // (on Mac OS X 10.3, command does not modify behavior)
439            inheritModifierFlags = NO;
440        }
441        [super mouseDown: ICCF_MouseDownEventWithModifierFlags(e, inheritModifierFlags)];
442        // we don't actually get a mouseUp event, just wait for mouseDown to return
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!");
448        if (abs(downPt.x - upPt.x) <= kICHysteresisPixels && abs(downPt.y - upPt.y) <= kICHysteresisPixels) {
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            }
457        }
458    } else {
459        [super mouseDown: e];
460    }
461#if ICCF_DEBUG
462    down = NO;
463#endif
464}
465
466@end
Note: See TracBrowser for help on using the repository browser.