1800 words on Software
Arghl! From time to time I have these sweet ideas for programs which look like they should be nice and easy to do. And the wonderful thing about Cocoa is that it does most of the heavy lifting and let you get right into it to make that little project work. However, I regularly run into roadblocks with this kind of strategy. Which – seeing that I don’t want to sit around ages trying to figure out how my understanding of the documentation differs from what it means or implementing stuff which looks like it should work out of the box – spoils the fun. Probably most of these problems aren’t all that hard to solve but doing so will likely consume a lot of time. And that’s where the line between small fun projects and professional programming is drawn.
Don’t get me wrong! Cocoa is one of the best environments I have seen for this kind of low-fi approach to ‘programming’. It probably even beats RealBasic by not being much harder but significantly more powerful. But still there are endless sources of frustration around. I’ll take the liberty to list a few examples. And invite you to tell me an obvious two-line solution for that which I would have already seen, had I read the documentation thoroughly.
To me it looks as if NSComboBox is a total bitch. In these modern days of bindings and advanced magic in Cocoa, I totally expected that I could just hook up an NSComboBox to a key in my user defaults and voilà, I automatically have a list of previously used values around. Let’s just say that didn’t work. Not at all. And I had to handle the storing, loading, and management of that list myself.
It turned out that the code for achieving that was neither particularly tricky nor was it particularly long. I ‘just’ needed to implement a data source for the combo box which loads the array of historical values straight from user defaults. And then I had to watch changed values of the combo box to add new items to that array of historical values in user defaults. Altogether, a simple thing to do thanks to KVO. (Let me know if you think having the code for this would help you, I’m just too lazy to prettify it for the web now.)
But while implementing this didn’t take long, learning that using bindings and controller objects for this purpose doesn’t work is a bad idea took a while. And a very frustrating while that was. In fact, it’s a common phenomenon I have experienced with bindings (or CoreData): it’s brilliant stuff as long as you want to do the same things Apple show you in the tutorials. But at least one of Cocoa or your understand of what’s going on starts falling apart quickly when you leave that realm. And as the underlying ‘magic’ is quite involved, it ends up being tricky/impossible to figure out why exactly things don’t work.
When you’re dealing with text views, Cocoa’s automatic drag and drop handling is wonderful. Because you get the whole deal for free. But once you start having a window with a number of text fields and the (unseen but still dreaded) field editor comes into play you (or at least I) quickly lose control about what’s going on.
In fact, this is a general problem which you can easily experience while using Cocoa applications. An example would be Mail: If your text cursor is in the To field you simply cannot drag text into the Subject field. That only works while the text cursor is in the Subject field, which is kind of pointless. Many other applications have the same problem. And – if I understand things correctly – the problem here is that when there are multiple fields Cocoa will only have the infrastructure for doing editing, the field editor, around once and simply switch it to the field you are currently editing. That’s probably a clever and economic thing to do. But it isn’t integrated with drag and drop. Which means you don’t have the field editor around when you drag to a field that doesn’t have the text cursor in it and thus things fail to work.
NSComboBoxes suffer from this problem as well. And as they are essentially text fields plus a menu, you have the additional problem that they also behave like text fields. I.e. when you happen to have the cursor inside them they’ll accept a text drag. And that drag will not replace the complete text but rather insert the dragged text. As you’d exepect for dragged text, of course. But this isn’t particularly useful when the dragged text is a file path, say.
[All right I hear you (and me) say that file paths are Wrong anyway, but dealing with proper file references in Cocoa is another of those painful topics. Generally, people tend to do what’s easy to do, so Cocoa just discourages people to do certain things properly and makes me end up with plenty of ‘evil!’ comments in source code.]
I didn’t find a way yet to solve this with reasonably little effort. Although it sounds like it should be a common combination of problems that have been solved over and over again.
My next idea was to accept drags in a QCView. That may seem exotic, but I figured it should be possible (and I’m still puzzled why this didn’t work). QCView is a new X.4 class, so perhaps it’s just buggy or something, no idea.
As this is a plain old NSView subclass I figured I should just be able to subclass it, register for drags in an overridden -initWithFrame: method and then also reply to the dragging protocol methods. I did just that, plus using my own subclass in the nib file of course. And it seems to do exactly nothing. Which I don’t get. The debugger says that the class used actually has the type of my subclass but my overridden -initWithFrame: is never called, which probably accounts for the rest not working. No idea what’s going on there. Perhaps something’s wired up wrongly? But what could that be?
Solving this problem turned out to be nice and easy after Steffen reminded me that objects coming from NIB files will not be inited when being unarchived, but they do receive the -awakeFromNib message which is the correct opportunity to listen for drag and drop. Bad thing is, I actually used to know that at some stage…
TextEdit implements a neat feature that camouflages its rudeness of always opening an empty document when the application is launched or clicked in the Dock: If you open a file with TextEdit while it’s just displaying that blank document, then the opened file simply replaces the empty document. That’s a reasonably good compromise and I thought I want it as well.
Probably the way to get this behaviour is to implement application:openFile: in the application’s delegate and do the necessary replacing there because it needs to be done before a new document class is initialised. Oddly, the TextEdit example project doesn’t seem to use the normal NSDocument class, so I was left a little puzzled at least.
UTIs are a great idea and the way to go for the future. In fact they were tacitly introduced in X.3 and see quite a bit of usage by Spotlight these days and allegedly also in pasteboard handling (although it’s admittedly not really clear to me how exactly that works and how it plays together with the older way of doing things).
But apart from that there just seems to be extremely little support for UTIs in Cocoa. Say your application can open movie files. Shouldn’t it be enough to tell the system that it can open anything that conforms to public.movie? At least I thought that should do the trick. Just that it doesn’t work. So what else can be done about this? Particularly if you don’t want to be too specific and need to update your application whenever QuickTime learns new tricks.
A nice thing of the hierarchy in UTIs is (would be) the inheritance. There are gazillions of movie file types identified by numerous things in the range between a ‘MooV’ creator code and the characters ‘.avi’ at the end of a file name. Shouldn’t it be reasonably simple to handle all of these in a single go?
Admittedly the file types as they exist today don’t work 100% here either. Which is the curse of container formats that carry information about their content hidden inside rather than sharing it with the system. From the outside a QuickTime VR file may come with the same ‘MooV’/’TVOD’ type and creator codes as a film (only the really old school ones will have a ‘vrod’ creator code), yet it is quite a different beast. And, similarly, very few AVI files can be played by an out-of-the-box QuickTime but once Perian has been installed, they’ll play just fine. It’s really hard to judge what works and what doesn’t in this context. And I wonder how long it will take to get an operating system that handles this well enough so your Dock icon already refuses accepting the film drags you cannot handle.
Another question is: How can I learn the UTI of a file? Say I accept file drags and only want to accept files of a different type. If I can learn the UTI of a dragged file, it should be possible or even easy to check whether it conforms to whatever I am able to accept, public.movie, say. But I didn’t find a way to learn the UTI of a file type directly. I assume that using Spotlight and doing something analogous to the mdls command could achieve that. But ever since Spotlight broke on my computer and the system stalls every process that dares to touch Spotlight for half a minute, I started thinking that it’s probably unwise to use Spotlight in your application unless you really need to search and are willing to handle such failures gracefully. Which leaves me clueless who such basic 21st century file type information could be found out by a Cocoa application.
Thanks to Michael’s helpful comment I was pointed towards LaunchServices for this which do the trick. It’s not nice angle bracket Cocoa but it’s not obscenely complicated either. I found Apple’s slightly more specific and complicated QA 1518 technote helpful here which demonstrates the relevant calls in practice along with the proper use of CFRelease.
Altogether I am not particularly amused. Everything sounded so good on paper but then things just started breaking down.
I think that the dragging issue in Mail is specific to the Subject field. With the cursor in the To field, I can drag to Cc or Reply-To without a problem.
I believe the recommended way to get the UTI of a file is to call LSCopyItemAttribute() with kLSItemContentType.
Jeff, as far as I can tell it’s the To/CC/BCC fields which are special here, though. As they are not just simple text fields but will contain extra code to accept dragged addresses as well. They also draw a black border around the themselves while a dragged item is hovering, which ‘normal’ text fields don’t do.
Thanks Michael, I’ll try that out. Hopefully I won’t get lost in the intricacies of the non Cocoa stuff.
I had a similar UTI-related requirement few weeks ago, and this is what I found, maybe it helps you:
How can I use Uniform Type Identifiers (UTIs) to determine if a given file path is an image file? http://developer.apple.com/qa/qa2007/qa1518.html
Michael Tsai: The Launch Services stuff does just what I want. And luckily the non-Cocoa stuff wasn’t as scary as I feared it might be. Thanks a lot.
Michal Benkur: Thanks for the link. I ran into exactly that document when following Michael’s hint today and it really is helpful.
I know almost half a year has passed, but how bout NSWorkspace to get a file’s UTI and check its inheritance:
NSWorkspace *workSpace = [NSWorkspace sharedWorkspace]; NSString *myType = [workSpace typeOfFile:myFilePath error:&error]; NSError *error; BOOL typeIsValid; if (error == nil) { typeIsValid = [workSpace type:myType conformsToType:@"my.UTI_Type"]; }
It seems that this NSWorkspace method would have been very helpful at the time Tónio. Unfortunately it only started to exist in Mac OS X.5, so it wasn’t really an option back then.
I was wanting to do the same thing with NSDocument: replace the Untitled window with the first file the user opens. I settled for closing the Untitled file when the user opens a file. Here’s what I wound up with:
//If the only open window is Untitled, close it. NSArray *docs = [[NSDocumentController sharedDocumentController] documents]; if ([docs count] == 1 && [[[docs objectAtIndex:0] displayName] compare:@"Untitled" options:NSCaseInsensitiveSearch range:NSMakeRange(0,8)] == NSOrderedSame) { [[docs objectAtIndex:0] close]; }
Yes, I know it will fail in another language, but this is an in-house project and the chances of it ever being used in a language other than English are infinitesimal. I’m not going to lose any sleep over the hack.
I found this post because I’m also getting ready to bind an NSComboBox to user defaults and also found out that it isn’t magically automatic like I thought it would be. I’d love to see your code, even if it isn’t prettified. It’ll save me some time.
@Steve:
For not creating duplicate documents, I nicked code from the TextEdit example project and simplified that for my purposes. The ‘right’ way to do that seems to be to use an NSDocumentController.
The ‘solution’ I found for the combo boxes is quite unspectacular. I’m just doing it the traditional way without bindings but using a data source class that has code to populate the fields from user defaults.
The code for the data source looks like this:
@implementation TextureStringComboBoxDataSource - (int)numberOfItemsInComboBox:(NSComboBox *)aComboBox { NSArray * a = [[NSUserDefaults standardUserDefaults] objectForKey:TEXTURESTRINGARRAYKEY]; if (a) { return [a count] + [DEFAULTTEXTURESTRINGS count]; } else { return [DEFAULTTEXTURESTRINGS count]; } } - (id)comboBox:(NSComboBox *)aComboBox objectValueForItemAtIndex:(int)index { NSArray * a = [[NSUserDefaults standardUserDefaults] objectForKey:TEXTURESTRINGARRAYKEY]; NSArray * myItems; if (a) { myItems = [a arrayByAddingObjectsFromArray:DEFAULTTEXTURESTRINGS]; } else { myItems = DEFAULTTEXTURESTRINGS; } return [myItems objectAtIndex:index]; } @end
In addition for that I have code in my document class which watches the values that were entered and then adds them to the array in user defaults if necessary.
- (void) observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if ([keyPath isEqualToString:@"textureString"]) { [self updateDefaultsForKey:@"textureString" withDefaultsArrayKey:TEXTURESTRINGARRAYKEY defaultArray:DEFAULTTEXTURESTRINGS]; } } - (void) updateDefaultsForKey:(NSString*) valueKey withDefaultsArrayKey:(NSString *) defaultsKey defaultArray: (NSArray*) defaultArray { NSString * newValue = [self valueForKey:valueKey]; if (![defaultArray containsObject: newValue]) { NSArray * a = [[NSUserDefaults standardUserDefaults] objectForKey:defaultsKey]; if (a) { NSMutableArray * b = [[a mutableCopy] autorelease]; [b removeObject:newValue]; [b insertObject:newValue atIndex:0]; if ([b count] > 10) { [b removeLastObject]; } a = b; } else { a = [NSArray arrayWithObject:newValue]; } [[NSUserDefaults standardUserDefaults] setObject:a forKey:defaultsKey]; } }
I’m afraid that code isn’t as refined as it could be but perhaps it’s a starting point.
Thanks for posting the code. It was a great starting point and I’ve got a version up and running.