Single-click renaming in NSTableView

Greetings, programs! Welcome to another installment of…um…this blog. I’m going to talk about the very important topic of renaming, because nobody is happy with their given name. Can you imagine Eleanor Gow wearing a Ralph Lifschitz dress? When I was born, I was destined to become Kid Dyn-O-Mite!

In earlier versions of Vienna, double-clicking a feed in the folders list started editing the feed’s name. The folders list is an NSOutlineView, which is a subclass of NSTableView and inherits much of the superclass behavior, including the default behavior of editing when a table cell is double-clicked. Daniel Jalkut suggested to me that double-clicking should open a feed’s home page, because the user will probably want to do that much more often than rename the feed. I agreed, but in order to follow his suggestion, I had to override the default behavior of NSOutlineView. It took me six revisions to finally get this right. (svn commit -m "D'oh!") The same technique works for both NSOutlineView and NSTableView, so I’ll examine the more general case.

According to the class reference for NSTableView, you need to do four things to override the default double-clicking behavior.

  1. Make the table cell uneditable. One way of doing this would be with the delegate method tableView:shouldEditTableColumn:row:.
  2. Set the double action for the table (setDoubleAction:).
  3. Set the action for the table (setAction:).
  4. Set the target for the table (setTarget:).

However, getting the single-click and double-click behaviors you desire is not as simple as setting the action and double action. The catch is that the table’s action method gets called for many different reasons. For example, the action method gets called as a result of the first click of a double-click. (Ted: No way! Bill: Yes way!) The action method also gets called when you click to make the table the first responder or to change the selection in the table. The irony is—isn’t it ironic, don’t you think?—that the change in table selection or first responder status is a result of your click, but the action method gets called after tableViewSelectionDidChange: or becomeFirstResponder. It’s like the runtime is prescient! There must be some spice in that Cocoa.

What you want is for a single-click to trigger editing only when the table is already the first responder and not when changing the cell selection or double-clicking the cell, but you need to take into account that the selection can change via mouse or keyboard, and the table can become first responder in any number of ways:

  1. As the initialFirstResponder of the window
  2. As the recipient of a mouse click
  3. As the nextKeyView of the first responder
  4. As the recipient of a makeFirstResponder: message

Are you exhausted yet? Never fear, because I’ve done the rest of the work for you. The trick to weeding out those ‘retrospective’ single-clicks is to set a short timer that prevents single-click renaming after your non-renaming events.

The following code is adapted from Vienna’s FoldersTree and FoldersView classes. I know it’s bad form to combine your model, view, and controller in one class, but JJTableView does have the virtues of brevity, which is appreciated on the web, and full functionality, when applied to a table in Interface Builder. This code is released under the SHAG license: if you use it in your app and become rich and famous while I linger in poverty and obscurity, I will Silently Hold A Grudge.

#import <Cocoa/Cocoa.h>

@interface JJTableView : NSTableView {
    NSMutableArray * array;
    BOOL canRename;
    IBOutlet NSTextField * textField;
    NSTimer * timer;
}
-(void)doClick:(id)sender;
-(void)doDoubleClick:(id)sender;
-(void)enableClickToRenameAfterDelay;
-(void)enableClickToRenameByTimer:(id)sender;
-(void)renameByTimer:(id)sender;
-(void)startTimerWithTimeInterval:(NSTimeInterval)seconds selector:(SEL)selector;
-(void)stopTimer;
@end

@implementation JJTableView

// NSNibAwaking
-(void)awakeFromNib {
    NSLog(@"awakeFromNib");
    array = [[NSMutableArray alloc] initWithObjects:
        @"1", @"2", @"3", @"4", @"5", nil];
    canRename = NO;
    timer = nil;
    [self setDataSource:self];
    [self setDelegate:self];
    [self setAction:@selector(doClick:)];
    [self setDoubleAction:@selector(doDoubleClick:)];
    [self setTarget:self];
    [self setNextKeyView:textField];
    [textField setNextKeyView:self];
}

// NSResponder (super)
-(BOOL)becomeFirstResponder {
    NSLog(@"becomeFirstResponder");
    BOOL flag = [super becomeFirstResponder];
    if (flag) {
        [self enableClickToRenameAfterDelay];
    }
    return flag;
}

// NSObject (super)
-(void)dealloc {
    [self stopTimer];
    [array release];
    [super dealloc];
}

// Action
-(void)doClick:(id)sender {
    NSLog(@"doClick:");
    if (canRename) {
        int row = [self clickedRow];
        if (row >= 0) {
            [self startTimerWithTimeInterval:0.5 selector:@selector(renameByTimer:)];
        }
    }
}

// DoubleAction
-(void)doDoubleClick:(id)sender {
    NSLog(@"doDoubleClick:");
    [self enableClickToRenameAfterDelay];
}

-(void)enableClickToRenameAfterDelay {
    canRename = NO;
    [self startTimerWithTimeInterval:0.2
        selector:@selector(enableClickToRenameByTimer:)];
}

-(void)enableClickToRenameByTimer:(id)sender {
    NSLog(@"enableClickToRenameByTimer:");
    canRename = YES;
}

-(void)renameByTimer:(id)sender {
    if (canRename) {
        int row = [self selectedRow];
        if (row != -1) {
            [self editColumn:0 row:row withEvent:nil select:YES];
        }
    }
}

-(void)startTimerWithTimeInterval:(NSTimeInterval)seconds selector:(SEL)selector {
    [self stopTimer];
    timer = [[NSTimer scheduledTimerWithTimeInterval:seconds
        target:self
        selector:selector
        userInfo:nil
        repeats:NO] retain];
}

-(void)stopTimer {
    if (timer != nil) {
        if ([timer isValid]) {
            [timer invalidate];
        }
        [timer release];
    }
}

// NSTableDataSource
-(int)numberOfRowsInTableView:(NSTableView *)tableView {
    return (int)[array count];
}

// NSTableDataSource
-(id)tableView:(NSTableView *)tableView
        objectValueForTableColumn:(NSTableColumn *)tableColumn row:(int)rowIndex {
    id value = nil;
    if ((rowIndex >= 0) && ((unsigned int)rowIndex < [array count])) {
        value = [array objectAtIndex:(unsigned int)rowIndex];
    }
    return value;
}

// NSTableDataSource
-(void)tableView:(NSTableView *)tableView
        setObjectValue:(id)object forTableColumn:(NSTableColumn *)tableColumn
        row:(int)rowIndex {
    if ((object != nil) && (rowIndex >= 0) && ((unsigned int)rowIndex < [array count])) {
        [array replaceObjectAtIndex:(unsigned int)rowIndex withObject:object];
    }
}

// NSTableView delegate
-(BOOL)tableView:(NSTableView *)tableView
        shouldEditTableColumn:(NSTableColumn *)tableColumn row:(int)rowIndex {
    return NO;
}

// NSTableView delegate
-(void)tableViewSelectionDidChange:(NSNotification *)notification {
    NSLog(@"tableViewSelectionDidChange:");
    [self enableClickToRenameAfterDelay];
}

@end

2 Responses to “Single-click renaming in NSTableView”

  1. Peter Hosey says:

    Suggestion: Put that into a tbz or tgz or zip file.

    Meanwhile, I’ll add this to my queue for my open source links list. :)

  2. Jeff says:

    Ok, the project can be downloaded at ClickRename.zip. I didn’t bother to do anything with the build configurations.