Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to draw an inline style label (or button) inside NSMenuItem

When App Store has updates, it shows an inline style element in the menu item, like '1 new' in the screenshot below:

enter image description here

Another place we can see this kind of menu is 10.10 Yosemite's share menu. When you install any app that adds a new share extension, the 'More' item from the share menu will show 'N new' just as the app store menu.

The 'App Store...' item looks to be a normal NSMenuItem. Is there an easy way to implement this or are there any APIs supporting it without setting up a custom view for the menu item?

like image 704
James Chen Avatar asked Oct 24 '14 12:10

James Chen


1 Answers

"Cocoa" NSMenus are actually built entirely on Carbon, so while the Cocoa APIs don't expose much functionality you can dip down into Carbon-land and get access a lot more power. That's what Apple does, anyway – the Apple Menu items are subclassed from IBCarbonMenuItem, as can be seen here:

/System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/Resources/English.lproj/StandardMenus.nib/objects.xib

Unfortunately the 64-bit Carbon APIs seem to be riddled with bugs and missing functions, which makes it much harder to install a working draw handler than compared to a 32-bit version. Here's a hacky version I came up with:

#import <Carbon/Carbon.h>

OSStatus eventHandler(EventHandlerCallRef inHandlerRef, EventRef inEvent, void *inUserData) {
  OSStatus ret = 0;

  if (GetEventClass(inEvent) == kEventClassMenu) {
    if (GetEventKind(inEvent) == kEventMenuDrawItem) {
      // draw the standard menu stuff
      ret = CallNextEventHandler(inHandlerRef, inEvent);

      MenuTrackingData tracking_data;
      GetMenuTrackingData(menuRef, &tracking_data);

      MenuItemIndex item_index;
      GetEventParameter(inEvent, kEventParamMenuItemIndex, typeMenuItemIndex, nil, sizeof(item_index), nil, &item_index);

      if (tracking_data.itemSelected == item_index) {
        HIRect item_rect;
        GetEventParameter(inEvent, kEventParamMenuItemBounds, typeHIRect, nil, sizeof(item_rect), nil, &item_rect);

        CGContextRef context;
        GetEventParameter(inEvent, kEventParamCGContextRef, typeCGContextRef, nil, sizeof(context), nil, &context);

        // first REMOVE a state from the graphics stack, instead of pushing onto the stack
        // this is to remove the clipping and translation values that are completely useless without the context height value
        extern void *CGContextCopyTopGState(CGContextRef);
        void *state = CGContextCopyTopGState(context);

        CGContextRestoreGState(context);

        // draw our content on top of the menu item
        CGContextSetRGBFillColor(context, 0.0, 0.0, 0.0, 0.5);
        CGContextFillRect(context, CGRectMake(0, item_rect.origin.y - tracking_data.virtualMenuTop, item_rect.size.width, item_rect.size.height));

        // and push a dummy graphics state onto the stack so the calling function can pop it again and be none the wiser
        CGContextSaveGState(context);
        extern void CGContextReplaceTopGState(CGContextRef, void *);
        CGContextReplaceTopGState(context, state);

        extern void CGGStateRelease(void *);
        CGGStateRelease(state);
      }
    }
  }
}

- (void)beginTracking:(NSNotification *)notification {
  // install a Carbon event handler to custom draw in the menu
  if (menuRef == nil) {
    extern MenuRef _NSGetCarbonMenu(NSMenu *);
    extern EventTargetRef GetMenuEventTarget(MenuRef);

    menuRef = _NSGetCarbonMenu(menu);
    if (menuRef == nil) return;

    EventTypeSpec events[1];
    events[0].eventClass = kEventClassMenu;
    events[0].eventKind = kEventMenuDrawItem;

    InstallEventHandler(GetMenuEventTarget(menuRef), NewEventHandlerUPP(&eventHandler), GetEventTypeCount(events), events, nil, nil);
  }

  if (menuRef != nil) {
    // set the kMenuItemAttrCustomDraw attrib on the menu item
    // this attribute is needed in order to receive the kMenuEventDrawItem event in the Carbon event handler
    extern OSStatus ChangeMenuItemAttributes(MenuRef, MenuItemIndex, MenuItemAttributes, MenuItemAttributes);
    ChangeMenuItemAttributes(menuRef, item_index, kMenuItemAttrCustomDraw, 0);
  }
}

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
  menu = [[NSMenu alloc] initWithTitle:@""];

  // register for the BeginTracking notification so we can install our Carbon event handler as soon as the menu is constructed
  [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(beginTracking:) name:NSMenuDidBeginTrackingNotification object:menu];
}

First it registers for a BeginTracking notification, as _NSGetCarbonMenu only returns a valid handle after the menu has been constructed and BeginTracking is called before the menu is drawn.

Then it uses the notification callback to get the Carbon MenuRef and attach a standard Carbon event handler to the menu.

Normally we could simply take the kEventParamMenuContextHeight event parameter and flip the CGContextRef and begin drawing, but that parameter is only available in 32-bit mode. Apple's documentation recommends using the height of the current port when that value is not available, but that too is only available in 32-bit mode.

So since the graphics state given to us is useless, pop it from the stack and use the previous graphics state. It turns out that this new state is translated to the virtual top of the menu, which can be retrieved using GetMenuTrackingData.virtualMenuTop. The kEventParamVirtualMenuTop value is also incorrect in 64-bit mode so it has to use GetMenuTrackingData.

It's hacky and absurd, but it's better than using setView and reimplementing the entire menu item behavior. The menu APIs on OS X are a bit of a mess.

like image 99
BonzaiThePenguin Avatar answered Nov 13 '22 07:11

BonzaiThePenguin