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

Last change on this file since 342 was 322, checked in by Nicholas Riley, 17 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.