12

I have the following code: (can be copy-pasted to New macOS project)

import Cocoa
import SwiftUI

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
    var statusBarItem: NSStatusItem!

    func applicationDidFinishLaunching(_ aNotification: Notification) {

        let statusBar = NSStatusBar.system
        statusBarItem = statusBar.statusItem(
            withLength: NSStatusItem.squareLength)
        statusBarItem.button?.title = ""

        // Setting action
        statusBarItem.button?.action = #selector(self.statusBarButtonClicked(sender:))
        statusBarItem.button?.sendAction(on: [.leftMouseUp])

        let statusBarMenu = NSMenu(title: "Status Bar Menu")
        statusBarMenu.addItem(
            withTitle: "Order an apple",
            action: #selector(AppDelegate.orderAnApple),
            keyEquivalent: "")

        statusBarMenu.addItem(
            withTitle: "Cancel apple order",
            action: #selector(AppDelegate.cancelAppleOrder),
            keyEquivalent: "")

        // Setting menu
        statusBarItem.menu = statusBarMenu
    }

    @objc func statusBarButtonClicked(sender: NSStatusBarButton) {
        let event = NSApp.currentEvent!

        if event.type ==  NSEvent.EventType.rightMouseUp {
            print("Right click!")
        } else {
            print("Left click!")
        }
    }

    @objc func orderAnApple() {
        print("Ordering a apple!")
    }


    @objc func cancelAppleOrder() {
        print("Canceling your order :(")
    }

}

Actual behaviour: Menu opens on both left and right click, statusBarButtonClicked is not triggered.
After removing this line:

statusBarItem.menu = statusBarMenu

statusBarButtonClicked triggers on left click, menu doesn't show up (as expected)

Desired behaviour: Menu opens on right click, on left click menu doesn't open, action is triggered.
How do I achieve it?

EDIT

I managed to achieve desired behavior with help of @red_menace comment:

import Cocoa
import SwiftUI

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
    var statusBarItem: NSStatusItem!
    var menu: NSMenu!

    func applicationDidFinishLaunching(_ aNotification: Notification) {

        let statusBar = NSStatusBar.system
        statusBarItem = statusBar.statusItem(
            withLength: NSStatusItem.squareLength)
        statusBarItem.button?.title = ""

        // Setting action
        statusBarItem.button?.action = #selector(self.statusBarButtonClicked(sender:))
        statusBarItem.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])

        let statusBarMenu = NSMenu(title: "Status Bar Menu")
        statusBarMenu.addItem(
            withTitle: "Order an apple",
            action: #selector(AppDelegate.orderAnApple),
            keyEquivalent: "")

        statusBarMenu.addItem(
            withTitle: "Cancel apple order",
            action: #selector(AppDelegate.cancelAppleOrder),
            keyEquivalent: "")

        // Setting menu
        menu = statusBarMenu
    }

    @objc func statusBarButtonClicked(sender: NSStatusBarButton) {
        let event = NSApp.currentEvent!

        if event.type ==  NSEvent.EventType.rightMouseUp {
            statusBarItem.popUpMenu(menu)
        } else {
            print("Left click!")
        }
    }

    @objc func orderAnApple() {
        print("Ordering a apple!")
    }


    @objc func cancelAppleOrder() {
        print("Canceling your order :(")
    }

}

But Xcode says that openMenu func is deprecated in 10.14 and tells me to Use the menu property instead. Is there I way to achieve desired behaviour with new API?

bapafes482
  • 444
  • 4
  • 18
  • 1
    Add `NSRightMouseUpMask` to `sendActionOn:`, and don't set the menu to the statusBarItem. Then just check the event in the action method and use `popUpStatusItemMenu:` to show the menu if the event is `NSEventTypeRightMouseUp`. Note that several of these methods have been deprecated in Catalina. – red_menace Jan 09 '20 at 20:20
  • @red_menace Thanks for answering :). You mean `popUpMenu:`? I can't find `popUpStatusItemMenu`. If so, popUpMenu works, but it's deprecated in 10.14. The warning reads 'use menu property instead' – bapafes482 Jan 09 '20 at 20:46
  • 1
    Sorry, `popUpStatusItemMenu` is Obj-C, Swift would be `popUpMenu`. In Catalina, `popUpMenu` and `sendAction` are deprecated NSStatusMenu methods. There is a `sendAction` method on NSButton, but the equivalent of `popUpMenu` would be something like set the status item menu and show it in the action method (e.g. via NSControl's `performClick`), then remove the menu when done so that it isn't used for the status item's normal behavior. I don't know Swift well enough to provide a decent example. – red_menace Jan 09 '20 at 22:47

5 Answers5

20

The usual way a to show a menu is to assign a menu to the status item, where it will be shown when the status item button is clicked. Since popUpMenu is deprecated, another way is needed to show the menu under different conditions. If you want the right click to use an actual status item menu instead of just showing a contextual menu at the status item location, the status item menu property can be kept nil until you want to show it.

I've adapted your code to keep the statusBarItem and statusBarMenu references separate, only adding the menu to the status item in the clicked action method. In the action method, once the menu is added, a normal click is performed on the status button to drop the menu. Since the status item will then always show its menu when the button is clicked, an NSMenuDelegate method is added to set the menu property to nil when the menu is closed, restoring the original operation:

import Cocoa

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
    // keep status item and menu separate
    var statusBarItem: NSStatusItem!
    var statusBarMenu: NSMenu!

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        let statusBar = NSStatusBar.system
        statusBarItem = statusBar.statusItem(withLength: NSStatusItem.squareLength)
        statusBarItem.button?.title = ""

        statusBarItem.button?.action = #selector(self.statusBarButtonClicked(sender:))
        statusBarItem.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])

        statusBarMenu = NSMenu(title: "Status Bar Menu")
        statusBarMenu.delegate = self
        statusBarMenu.addItem(
            withTitle: "Order an apple",
            action: #selector(AppDelegate.orderAnApple),
            keyEquivalent: "")
        statusBarMenu.addItem(
            withTitle: "Cancel apple order",
            action: #selector(AppDelegate.cancelAppleOrder),
            keyEquivalent: "")
    }

    @objc func statusBarButtonClicked(sender: NSStatusBarButton) {
        let event = NSApp.currentEvent!
        if event.type ==  NSEvent.EventType.rightMouseUp {
            print("Right click!")
            statusBarItem.menu = statusBarMenu // add menu to button...
            statusBarItem.button?.performClick(nil) // ...and click
        } else {
            print("Left click!")
        }
    }

    @objc func menuDidClose(_ menu: NSMenu) {
        statusBarItem.menu = nil // remove menu so button works as before
    }

    @objc func orderAnApple() {
        print("Ordering a apple!")
    }

    @objc func cancelAppleOrder() {
        print("Canceling your order :(")
    }

}
red_menace
  • 3,162
  • 2
  • 10
  • 18
  • Your method does not work for me. The menu is only shown when I remove the line `statusBarItem.button?.performClick(nil)`. And the menu is then only shown when I right click the button twice. Do you know another method to show the menu without using `performClick(nil)`? – D0miH Apr 30 '20 at 18:21
  • What exactly is "does not work" supposed to mean? Do you get an error? Is there something in the log? – red_menace Apr 30 '20 at 19:15
  • Ok I know why it didn't work. I was using an external monitor and appearently when calling `performClick()` the click was performed on the external monitor instead of the built in of my laptop. Now I just need to find a way to perform the click on the correct monitor... – D0miH May 01 '20 at 07:58
  • The click should be getting performed on the status button, wherever it is. – red_menace May 01 '20 at 13:13
  • I think the problem in this case is that when using an external monitor there are two status buttons (one for each monitor). I will try to find a way to make this work and post a solution here if i got one. – D0miH May 01 '20 at 13:35
  • Ok, so for anyone running into the same issue it seems like this is a bug in macOS Catalina: https://forums.developer.apple.com/thread/126072 Hopefully they will fix it soon. – D0miH May 01 '20 at 14:38
5

Here is possible approach. There might be more accurate calculations for menu position, including taking into account possible differences of userInterfaceLayoutDirection, but the idea remains the same - take possible events under manual control and make own decision about what to do on each event.

Important places commented in code. (Tested on Xcode 11.2, macOS 10.15)

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
    var statusBarItem: NSStatusItem!

    func applicationDidFinishLaunching(_ aNotification: Notification) {

        let statusBar = NSStatusBar.system
        statusBarItem = statusBar.statusItem(
            withLength: NSStatusItem.squareLength)
        statusBarItem.button?.title = ""

        // Setting action
        statusBarItem.button?.action = #selector(self.statusBarButtonClicked(sender:))
        statusBarItem.button?.sendAction(on: [.leftMouseUp, .rightMouseUp]) // << send action in both cases

        let statusBarMenu = NSMenu(title: "Status Bar Menu")
        statusBarMenu.addItem(
            withTitle: "Order an apple",
            action: #selector(AppDelegate.orderAnApple),
            keyEquivalent: "")

        statusBarMenu.addItem(
            withTitle: "Cancel apple order",
            action: #selector(AppDelegate.cancelAppleOrder),
            keyEquivalent: "")

        // Setting menu
        statusBarItem.button?.menu = statusBarMenu // << store menu in button, not item
    }

    @objc func statusBarButtonClicked(sender: NSStatusBarButton) {
        let event = NSApp.currentEvent!

        if event.type ==  NSEvent.EventType.rightMouseUp {
            print("Right click!")
            if let button = statusBarItem.button { // << pop up menu programmatically
                button.menu?.popUp(positioning: nil, at: CGPoint(x: -1, y: button.bounds.maxY + 5), in: button)
            }
        } else {
            print("Left click!")
        }
    }

    @objc func orderAnApple() {
        print("Ordering a apple!")
    }


    @objc func cancelAppleOrder() {
        print("Canceling your order :(")
    }

}
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Thanks. :) Just one more question.Why does the menu has top left and top right borders rounded? If I try to reposition it on y axis it just overlaps the menu bar. https://i.stack.imgur.com/KtTSY.png – bapafes482 Jan 10 '20 at 20:05
3

I'm using this code on macOS Catalina. 10.15.2. ( Xcode 11.3).
On left click It trigger action.
On right click it show menu.

//HEADER FILE
#import <Foundation/Foundation.h>
#import <Cocoa/Cocoa.h>
NS_ASSUME_NONNULL_BEGIN

@protocol MOSMainStatusBarDelegate

- (void) menuBarControllerStatusChanged: (BOOL) active;

@end


@interface MOSMainStatusBar : NSObject

@property (strong) NSMenu *menu;
@property (strong, nonatomic) NSImage *image;
@property (unsafe_unretained, nonatomic) id<MOSMainStatusBarDelegate> delegate;

- (instancetype) initWithImage: (NSImage *) image menu: (NSMenu *) menu;
- (NSStatusBarButton *) statusItemView;
- (void) showStatusItem;
- (void) hideStatusItem;


@end

//IMPLEMANTION FILE. 

#import "MOSMainStatusBar.h"

@interface MOSMainStatusBar ()

@property (strong, nonatomic) NSStatusItem *statusItem;

@end

@implementation MOSMainStatusBar

- (instancetype) initWithImage: (NSImage *) image menu: (NSMenu *) menu {
    self = [super init];
    if (self) {
        self.image = image;
        self.menu = menu;
    }
    return self;
}

- (void) setImage: (NSImage *) image {
    _image = image;
    self.statusItem.button.image = image;

}

- (NSStatusBarButton *) statusItemView {
    return self.statusItem.button;
}

- (void) showStatusItem {
    if (!self.statusItem) {
        self.statusItem = [[NSStatusBar systemStatusBar] statusItemWithLength:NSSquareStatusItemLength];

        [self initStatusItem10];

    }
}

- (void) hideStatusItem {
    if (self.statusItem) {
        [[NSStatusBar systemStatusBar] removeStatusItem:self.statusItem];
        self.statusItem = nil;
    }
}



- (void) initStatusItem10 {

    self.statusItem.button.image = self.image;


    self.statusItem.button.imageScaling =  NSImageScaleAxesIndependently;

    self.statusItem.button.appearsDisabled = NO;
    self.statusItem.button.target = self;
    self.statusItem.button.action = @selector(leftClick10:);

    __unsafe_unretained MOSMainStatusBar *weakSelf = self;

    [NSEvent addLocalMonitorForEventsMatchingMask:
     (NSEventMaskRightMouseDown | NSEventModifierFlagOption | NSEventMaskLeftMouseDown) handler:^(NSEvent *incomingEvent) {

         if (incomingEvent.type == NSEventTypeLeftMouseDown) {
             weakSelf.statusItem.menu = nil;
         }

         if (incomingEvent.type == NSEventTypeRightMouseDown || [incomingEvent modifierFlags] & NSEventModifierFlagOption) {

             weakSelf.statusItem.menu = weakSelf.menu;
         }

         return incomingEvent;
     }];
}

- (void)leftClick10:(id)sender {

    [self.delegate menuBarControllerStatusChanged:YES];
}
user1105951
  • 2,259
  • 2
  • 34
  • 55
  • My knowledge of objc is event worse than knowledge of swift, but I can see that you are setting menu on right click and setting it to nil on left click. Honestly, it looks a little bit like dirty workaround to me, since NSMenu provides popUp function (Asperi's answer). But maybe your approach is considered OK in macos development. Am I wrong? – bapafes482 Jan 10 '20 at 20:22
  • 2
    This is what I used (I got the basics from the web, and design it in my own class). If what you need is right click and left click, this is working code. I use it in my app for 10+ month, and never notice any problem. I don't see any "dirty" thing in this code. what I did not understand on the other answer is the position of menu . In my code I don't "touch" position = less future problems. – user1105951 Jan 11 '20 at 09:39
2

I'm using this code on Catalina. Rather than performClick like some of the other answers suggest, I had to manually position the popup. This works for me with external monitors.

func applicationDidFinishLaunching(_ aNotification: Notification) {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        statusItem.button?.action = #selector(onClick)
        statusItem.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
        
        menu = NSMenu()
        menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
        menu.delegate = self
        
    }
    
    @objc func onClick(sender: NSStatusItem) {
        
        let event = NSApp.currentEvent!
       
        if event.type == NSEvent.EventType.rightMouseUp {
            // right click, show quit menu
            statusItem.menu = menu;
            menu.popUp(positioning: nil, 
                at: NSPoint(x: 0, y: statusItem.statusBar!.thickness),
                in: statusItem.button)
        } else {
           // main click 
        }
    }
    @objc func menuDidClose(_ menu: NSMenu) {
        // remove menu when closed so we can override left click behavior
        statusItem.menu = nil
    }
0

Learning from the great answers already given in here I came up with an alternative.

The advantage of this approach is that you only set the NSMenu once and don't have to juggle with setting it or removing it anymore.

Setting up the NSStatusBarButton

let status = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
status.button!.image = NSImage(named: "status")
status.button!.target = self
status.button!.action = #selector(triggerStatus)
status.button!.menu = /* your NSMenu */
status.button!.sendAction(on: [.leftMouseUp, .rightMouseUp])

Receiving the action

@objc private func triggerStatus(_ button: NSStatusBarButton) {
    guard let event = NSApp.currentEvent else { return }
        
    switch event.type {
    case .rightMouseUp:
        NSMenu.popUpContextMenu(button.menu!, with: event, for: button)
    case .leftMouseUp:
            /* show your Popup or any other action */
    default:
        break
    }
}
vicegax
  • 4,709
  • 28
  • 37