24

How do you add the ability to right click on a row in an NSOutlineView so you can say delete an object or some other activity. (ie Like when you right click on a folder in the Apple Mail app)

I think I am half way there, I have a subclass of NSOutlineView that allows me to catch the right click and display a context menu based on the selected row rather than the row the mouse is clicking on.

@implementation NSContextOutlineView

    - (NSMenu *)defaultMenu {
        if([self selectedRow] < 0) return nil;
        NSMenu *theMenu = [[[NSMenu alloc] initWithTitle:@"Model browser context menu"] autorelease];
        [theMenu insertItemWithTitle:@"Add package" action:@selector(addSite:) keyEquivalent:@"" atIndex:0];
        NSString* deleteItem = [NSString stringWithFormat: @"Remove '%i'", [self selectedRow]];
        [theMenu insertItemWithTitle: deleteItem action:@selector(removeSite:) keyEquivalent:@"" atIndex:1];
        return theMenu;
    }

    - (NSMenu *)menuForEvent:(NSEvent *)theEvent {
        return [self defaultMenu];  
    }
@end

Sorry if the answer is obvious I just cant find any help on this online or in the documentation.

Thanks to Void for the answer, it lead me to using this:

- (NSMenu *)menuForEvent:(NSEvent *)theEvent {
    NSPoint pt = [self convertPoint:[theEvent locationInWindow] fromView:nil];
    id item = [self itemAtRow: [self rowAtPoint:pt]];
    return [self defaultMenuFor: item];
}
Jay
  • 19,649
  • 38
  • 121
  • 184
  • Without running it, that looks like it should work just fine. Does it not? If not, what problem are you having with it? – Peter Hosey Aug 21 '09 at 03:10
  • 4
    Also, don't use the NS prefix on your own classes. If Apple adds an NSContextOutlineView class to a future version of Cocoa, their class and yours will collide, and your app probably will not run. – Peter Hosey Aug 21 '09 at 03:10
  • 1
    I think the problem with the posted code is that it will use the selectedRow and not the row on which the right/ctrl-click was performed. That may or may not be the selected row. – VoidPointer Aug 21 '09 at 16:40
  • Yep "...based on the selected row rather than the row the mouse is clicking on" – Jay Aug 22 '09 at 02:15

6 Answers6

23

In your menuForEvent method you can find out which row the click occurred on. You can pass that as a parameter to your defaultMenu method -- maybe call it defaultMenuForRow:

-(NSMenu*)menuForEvent:(NSEvent*)evt 
{
    NSPoint pt = [self convertPoint:[evt locationInWindow] fromView:nil];
    int row=[self rowAtPoint:pt];
    return [self defaultMenuForRow:row];
}

Now you can build the menu for the row you found in the event...

-(NSMenu*)defaultMenuForRow:(int)row
{
    if (row < 0) return nil;

    NSMenu *theMenu = [[[NSMenu alloc] 
                                initWithTitle:@"Model browser context menu"] 
                                autorelease];
    [theMenu insertItemWithTitle:@"Add package" 
                          action:@selector(addSite:) 
                   keyEquivalent:@"" 
                         atIndex:0];
    [theMenu insertItemWithTitle:[NSString stringWithFormat:@"Remove '%i'", row] 
                          action:@selector(removeSite:) 
                   keyEquivalent:@"" 
                         atIndex:0];
    // you'll need to find a way of getting the information about the 
    // row that is to be removed to the removeSite method
    // assuming that an ivar 'contextRow' is used for this
    contextRow = row;

    return theMenu;        
}

Also, as already mentioned in the comments, you really shouldn't use the NS-prefix on your own classes. There is a potential for a clash in the future plus it will confuse everybody that is looking at your code - including yourself :)

Hope this helps...

VoidPointer
  • 17,651
  • 15
  • 54
  • 58
  • Thanks so very much! So many of these things are so simple to do, but only once you know how! – Jay Aug 22 '09 at 02:15
  • 2
    Sad we need to subclass `NSOutlineView` to achieve this. This functionality should be included in the delegate protocol already :-) – Nicolas Miari Aug 20 '17 at 12:52
13

Here is a Swift 2.0 example which uses a subclass and extends the default NSOutlineDelegate so you can define your menus in the delegate.

protocol MenuOutlineViewDelegate : NSOutlineViewDelegate {
    func outlineView(outlineView: NSOutlineView, menuForItem item: AnyObject) -> NSMenu?
}

class MenuOutlineView: NSOutlineView {

    override func menuForEvent(event: NSEvent) -> NSMenu? {
        let point = self.convertPoint(event.locationInWindow, fromView: nil)
        let row = self.rowAtPoint(point)
        let item = self.itemAtRow(row)

        if (item == nil) {
            return nil
        }

        return (self.delegate() as! MenuOutlineViewDelegate).outlineView(self, menuForItem: item!)
    }

}
rdougan
  • 7,217
  • 2
  • 34
  • 63
11

No need to subclass, its quite simple, and you can even customize the menu on the fly.

Declare an empty menu, set its delegate and set it on the outline views .menu property. As an added bonus this method works on both, outline and table views the same.

class OutlineViewController: NSViewController {

     private let contextMenu = NSMenu(title: "Context")
     
     override func viewDidLoad() {
        super.viewDidLoad()

        // other init stuff...

        contextMenu.delegate = self
        outlineView.menu = contextMenu
    }
}

extension OutlineViewController: NSMenuDelegate {

    func menuNeedsUpdate(_ menu: NSMenu) {
        // Returns the clicked row indices.
        // If the right click happens inside a selection, it is usually
        // the selected rows, if it appears outside of the selection it
        // is only the right clicked row with a blue border, as defined
        // in the `NSTableView` extension below.
        let indexes = outlineView.contextMenuRowIndexes

        menu.removeAllItems()
        
        // TODO: add/modify item as needed here before it is shown
    }
}

extension NSTableView {

    var contextMenuRowIndexes: IndexSet {
        var indexes = selectedRowIndexes

        // The blue selection box should always reflect the returned row indexes.
        if clickedRow >= 0
            && (selectedRowIndexes.isEmpty || !selectedRowIndexes.contains(clickedRow)) {
            indexes = [clickedRow]
        }

        return indexes
    }
}
3

Much later than the OP question, but for others like me wondering, here is my solution. It also needs subclassing NSOutlineView, which is not encouraged by Apple doc, anyway…

Rather than override menuForEvent: I override rightMouseDown:

- (void)rightMouseDown:(NSEvent *)event {
    NSPoint pt = [self convertPoint:[event locationInWindow] fromView:nil];
    NSInteger row = [self rowAtPoint:pt];
    id item = [self itemAtRow:row];
    NSMenu *menu;
    //set the menu to one you have defined either in code or IB through outlets
    self.menu = menu;
    [super rightMouseDown:event];
}

This has the advantage of keeping delegate calls to update the menu thereafter and also keeps row outlining on right click.

Max_B
  • 887
  • 7
  • 16
  • IMHO, this should be the approved answer, as it supports the row outlining when right-clicking, which feels way more natural from the user perspective. – MrAsterisco May 24 '21 at 18:45
  • Don't forget that those menu delegates and validators won't be aware of the item at the event's row. – technicalflaw Feb 10 '23 at 22:45
0

Swift 5.0 example based on @rdougan's answer:

protocol MenuOutlineViewDelegate : NSOutlineViewDelegate {
    func outlineView(_ outlineView: NSOutlineView, menuForItem item: Any?) -> NSMenu?
}

class MenuOutlineView: NSOutlineView {

    override func menu(for event: NSEvent) -> NSMenu? {
        let point = self.convert(event.locationInWindow, from: nil)
        let row = self.row(at: point)
        let item = item(atRow: row)

        return (delegate as! MenuOutlineViewDelegate).outlineView(self, menuForItem: item)
    }

}

Notable change: use Any? instead of Any(Object) to allow a context menu to appear on the "root". This is most useful when you want the entire navigation view (the empty areas) to remain right-clickable.

Parth Mehrotra
  • 2,712
  • 1
  • 23
  • 31
-1

If you prefer, you can attach the menu to the individual cell view or row view and build it with interface builder:

@implementation BSMotleyOutlineView

-(NSMenu *)menuForEvent:(NSEvent *)event
{
    NSPoint pt = [self convertPoint:[event locationInWindow] fromView:nil];
    NSInteger row = [self rowAtPoint:pt];
    if (row >= 0) {
        NSTableRowView* rowView = [self rowViewAtRow:row makeIfNecessary:NO];
        if (rowView) {
            NSInteger col = [self columnAtPoint:pt];
            if (col >= 0) {
                NSTableCellView* cellView = [rowView viewAtColumn:col];
                NSMenu* cellMenu = cellView.menu;
                if(cellMenu) {
                    return cellMenu;
                }
            }
            NSMenu* rowMenu = rowView.menu;
            if (rowMenu) {
                return rowMenu;
            }
        }
    }
    return [super menuForEvent:event];
}
@end
adib
  • 8,285
  • 6
  • 52
  • 91