NSView custom context menu and keys - cocoa

i have an NSCollectionView in my application's main window that manages a collection of custom NSView items. Each custom view has a context menu assigned to it. I want to add shortcut keys to some of the items, for example to associate a "delete" key with "remove item from collection" action. I've added key equivalents to context menu items through IB but the question is how do i make the collection items respond to the pressed keys?
I know that i can achieve this by adding this menu to the NSApp's main menu and keep track of the selected item. Is there any other way besides that?

You could add something like this to your NSCollectionView subclass:
- (BOOL)performKeyEquivalent:(NSEvent *)theEvent
{
BOOL rv = NO;
id firstResponder = self.window.firstResponder;
if ([firstResponder isKindOfClass:[NSView class]] && [firstResponder isDescendantOf:self]) {
// Note: performKeyEquivalent: messages come DOWN the view hierarchy, not UP the responder chain.
// Perform the key equivalent
}
if (!rv) {
rv = [super performKeyEquivalent:theEvent];
}
return rv;
}

Related

NSMenuItem custom view drawRect not always called

I'm using a custom view for NSMenu items so I can control the background colour via isHighlighted.
The issue is, if you use a combination of mouse and keyboard to navigate the menu, it's possible to have two items selected at once. This is because drawRect isn't being called on some items to dehighlight them
Has anyone else run into this?
NSMenuItems should be created using:
NSMenuItem *menuItem = [[NSMenuItem alloc] initWithTitle:#"" action:#selector(menuItemSelected:) keyEquivalent:#""];
where the selector menuItemSelected: is a valid method. isHighlighted won't be toggled if a valid action selector is not provided
Conform NSMenuDelegate and implement following function like this:
func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) {
for item in menu.items.compactMap({ return $0.isHighlighted ? $0 : nil }) {
item.view?.needsDisplay = true
}
}

Remove 'Layout Orientation' from NSTextView context menu

I have a NSTextView which has a ruler attached showing line numbers. If the user uses the 'Layout Orientation' -> 'Vertical' context menu, things go wonky. The applications intended purpose does not support a Vertical orientation anyway, so I would like to remove this context menu.
So far I have subclassed an NSTextView and overwrote the defaultMenu action:
+ (NSMenu *) defaultMenu
{
// Get our default menu
NSMenu * contextMenu =
[NSTextView defaultMenu];
for(NSInteger menuItemIndex = contextMenu.itemArray.count - 1;
menuItemIndex != -1;
--menuItemIndex)
{
NSMenuItem * menuItem =
[contextMenu itemAtIndex: menuItemIndex];
NSLog(#"%ld %#, %#",
menuItemIndex,
NSStringFromSelector(menuItem.action),
menuItem.title);
} // End of menuItem loop
return contextMenu;
} // End of defaultMenu
My original thought was that I could remove the menu item with a specific selector, but unfortunately the 'Layout Orientation' is a submenu, so it has the submenuAction: selector.
I could still remove the menu by comparing the title, but that seems like a poor way to do this and would probably break in a localized environment.
Any suggestions as to PROPERLY go about removing menu items from the NSTextView context menu? (Removing by index also seems hacky, as that could possible break on different versions of the OS).
Each submenu menuitem has a menu. Scan the submenus for action changeLayoutOrientation:.
You can disable the layout orientation menu items by implementing validateUserInterfaceItem:.
- (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)anItem {
if ([anItem action] == #selector(changeLayoutOrientation:))
return NO;
return [super validateUserInterfaceItem:anItem];
}

Changing the selection behaviour of NSCollectionView

In my Mac app I have a NSCollectionView with multi select enabled. In my app being able to select more than one item is the norm, and having to press cmd while clicking to select multiple items is frustrating some users and most don't realise they can do it (I get a lot of feature requests asking for multi select).
So, I want to change the behaviour so that:
when a user clicks a second item, the first item remains selected (without the need for holding cmd)
When a user clicks a selected item, the item is deselected
I've tried overriding setSelected on my own subclass of NSCollectionViewItem like so:
-(void)setSelected:(BOOL)flag
{
[super setSelected:flag];
[(MyView*)[self view] setSelected: flag];
[(MyView*)[self view] setNeedsDisplay:YES];
}
Calling super setSelected is required to make sure the collection view functions correctly, but it also seems to be what is responsible for the default behaviour.
What should I do instead?
You could try intercepting all left-mouse-down events using a local events monitor. Within this block you'd then work out if the click happened on your collection view. If it did, create a new event which mimics the event you intercepted but add in the command key mask if it isn't already present. Then, at the end of the block return your event rather than the one you intercepted. Your collection view will behave as if the user had pressed the command key, even though they haven't!
I had a quick go with this in a very simple demo app and it looks like a promising approach - though I expect you'll have to negotiate a few gotchas along the way.
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
[NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskFromType(NSLeftMouseDown)
handler:^NSEvent *(NSEvent *originalEvent) {
// Did this left down event occur on your collection view?
// If it did add in the command key
NSEvent *newEvent =
[NSEvent
mouseEventWithType: NSLeftMouseDown
location: originalEvent.locationInWindow
modifierFlags: NSCommandKeyMask // I'm assuming it's not already present
timestamp: originalEvent.timestamp
windowNumber: originalEvent.windowNumber
context: originalEvent.context
eventNumber: originalEvent.eventNumber
clickCount: originalEvent.clickCount
pressure:0];
return newEvent; // or originalEvent if it's nothing to do with your collection view
}];
}
Edit (by question author):
This solution is so heavily based on the original answer that this answer deserves credit (feel free to edit)
You can also intercept the mouse event by subclassing the NSCollectionView class and overriding mousedown like this:
#implementation MyCollectionView
-(void) mouseDown:(NSEvent *)originalEvent {
NSEvent *mouseEventWithCmd =
[NSEvent
mouseEventWithType: originalEvent.type
location: originalEvent.locationInWindow
modifierFlags: NSCommandKeyMask
timestamp: originalEvent.timestamp
windowNumber: originalEvent.windowNumber
context: originalEvent.context
eventNumber: originalEvent.eventNumber
clickCount: originalEvent.clickCount
pressure: originalEvent.pressure];
[super mouseDown: mouseEventWithCmd];
}
#end

Cocoa Storyboard Responder Chain

Storyboards for Cocoa apps seems like a great solution as I prefer the methodology you find in iOS. However, while breaking things up into separate view controllers makes a lot of logical sense, I'm not clear as to how to pass window control (toolbar buttons) or menu interaction down to the view controllers that care. My app delegate is the first responder and it receives the the menu or toolbar actions, however, how can I access the view controller that I need to get that message to? Can you just drill down into the view controllers hierarchy. If so, how do you get there from the app delegate since it's the first responder? Can you make the window controller the first responder instead. If so, how? In the storyboard? Where?
Since this is a high level question it may not matter, however, I am using Swift for this project if you're wondering.
I'm not sure if there is a "proper" way to solve this, however, I have come up with a solution that I'll use for now. First a couple of details
My app is a document based application so each window has an instance of the document.
The document the app uses can act as the first responder and forward any actions I've connected
The document is able to get a hold of the top level window controller and from there I am able to drill down through the view controller hierarchy to get to the view controller I need.
So, in my windowDidLoad on the window controller, I do this:
override func windowDidLoad() {
super.windowDidLoad()
if self.contentViewController != nil {
var vc = self.contentViewController! as NSSplitViewController
var innerSplitView = vc.splitViewItems[0] as NSSplitViewItem
var innerSplitViewController = innerSplitView.viewController as NSSplitViewController
var layerCanvasSplitViewItem = innerSplitViewController.splitViewItems[1] as NSSplitViewItem
self.layerCanvasViewController = layerCanvasSplitViewItem.viewController as LayerCanvasViewController
}
}
Which gets me the view controller (which controls the view you see outlined in red below) and sets a local property in the window view controller.
So now, I can forward the toolbar button or menu item events directly in the document class which is in the responder chain and therefore receives the actions I setup in the menu and toolbar items. Like this:
class LayerDocument: NSDocument {
#IBAction func addLayer(sender:AnyObject) {
var windowController = self.windowControllers[0] as MainWindowController
windowController.layerCanvasViewController.addLayer()
}
// ... etc.
}
Since the LayerCanvasViewController was set as a property of the main window controller when it got loaded, I can just access it and call the methods I need.
For the action to find your view controllers, you need to implement -supplementalTargetForAction:sender: in your window and view controllers.
You could list all child controllers potentially interested in the action, or use a generic implementation:
- (id)supplementalTargetForAction:(SEL)action sender:(id)sender
{
id target = [super supplementalTargetForAction:action sender:sender];
if (target != nil) {
return target;
}
for (NSViewController *childViewController in self.childViewControllers) {
target = [NSApp targetForAction:action to:childViewController from:sender];
if (![target respondsToSelector:action]) {
target = [target supplementalTargetForAction:action sender:sender];
}
if ([target respondsToSelector:action]) {
return target;
}
}
return nil;
}
I had the same Storyboard problem but with a single window app with no Documents. It's a port of an iOS app, and my first OS X app. Here's my solution.
First add an IBAction as you did above in your LayerDocument. Now go to Interface Builder. You'll see that in the connections panel to First Responder in your WindowController, IB has now added a Sent Action of addLayer. Connect your toolBarItem to this. (If you look at First Responder connections for any other controller, it will have a Received Action of addLayer. I couldn't do anything with this. Whatever.)
Back to windowDidLoad. Add the following two lines.
// This is the top view that is shown by the window
NSView *contentView = self.window.contentView;
// This forces the responder chain to start in the content view
// instead of simply going up to the chain to the AppDelegate.
[self.window makeFirstResponder: contentView];
That should do it. Now when you click on the toolbarItem it will go directly to your action.
I've been struggling with this question myself.
I think the 'correct' answer is to lean on the responder chain. For example, to connect a tool bar item action, you can select the root window controller's first responder. And then show the attributes inspector. In the attributes inspector, add your custom action (see photo).
Then connect your toolbar item to that action. (Control drag from your Toolbar item to the first responder and select the action you just added.)
Finally, you can then go to the ViewController (+ 10.10) or other object, so long as its in the responder chain, where you want to receive this event and add the handler.
Alternatively, instead of defining the action in the attributes inspector. You can simply write your IBAction in your ViewController. Then, go to the toolbar item, and control drag to the window controller's first responder -- and select the IBAction you just added. The event will then travel thru the responder chain until received by the view controller.
I think this is the correct way to do this without introducing any additional coupling between your controllers and/or manually forwarding the call.
The only challenge I've run into -- being new to Mac dev myself -- is sometimes the Toolbar item disabled itself after receiving the first event. So, while I think this is the correct approach, there are still some issues I've run into myself.
But I am able to receive the event in another location without any additional coupling or gymnastics.
As i'm a very lazy person i came up with the following solution based on Pierre Bernard
's version
#include <objc/runtime.h>
//-----------------------------------------------------------------------------------------------------------
IMP classSwizzleMethod(Class cls, Method method, IMP newImp)
{
auto methodReplacer = class_replaceMethod;
auto methodSetter = method_setImplementation;
IMP originalImpl = methodReplacer(cls, method_getName(method), newImp, method_getTypeEncoding(method));
if (originalImpl == nil)
originalImpl = methodSetter(method, newImp);
return originalImpl;
}
// ----------------------------------------------------------------------------
#interface NSResponder (Utils)
#end
//------------------------------------------------------------------------------
#implementation NSResponder (Utils)
//------------------------------------------------------------------------------
static IMP originalSupplementalTargetForActionSender;
//------------------------------------------------------------------------------
static id newSupplementalTargetForActionSenderImp(id self, SEL _cmd, SEL action, id sender)
{
assert([NSStringFromSelector(_cmd) isEqualToString:#"supplementalTargetForAction:sender:"]);
if ([self isKindOfClass:[NSWindowController class]] || [self isKindOfClass:[NSViewController class]]) {
id target = ((id(*)(id, SEL, SEL, id)) originalSupplementalTargetForActionSender)(self, _cmd, action, sender);
if (target != nil)
return target;
id childViewControllers = nil;
if ([self isKindOfClass:[NSWindowController class]])
childViewControllers = [[(NSWindowController*) self contentViewController] childViewControllers];
if ([self isKindOfClass:[NSViewController class]])
childViewControllers = [(NSViewController*) self childViewControllers];
for (NSViewController *childViewController in childViewControllers) {
target = [NSApp targetForAction:action to:childViewController from:sender];
if (NO == [target respondsToSelector:action])
target = [target supplementalTargetForAction:action sender:sender];
if ([target respondsToSelector:action])
return target;
}
}
return nil;
}
// ----------------------------------------------------------------------------
+ (void) load
{
Method m = nil;
m = class_getInstanceMethod([NSResponder class], NSSelectorFromString(#"supplementalTargetForAction:sender:"));
originalSupplementalTargetForActionSender = classSwizzleMethod([self class], m, (IMP)newSupplementalTargetForActionSenderImp);
}
// ----------------------------------------------------------------------------
#end
//------------------------------------------------------------------------------
This way you do not have to add the forwarder code to the window controller and all the viewcontrollers (although subclassing would make that a bit easier), the magic happens automatically if you have a viewcontroller for the window contentview.
Swizzling always a bit dangerous so it is far not a perfect solution, but I've tried it with a very complex view/viewcontroller hierarchy that using container views, worked fine.

Changing NSMenuItem depending on user selection

I am working on a Cocoa document based application. I have a menu item in the file menu named "Export Things…". This menu item is connected to the first responder, and calls a selector in MyDocument. So far so good.
I would like to change the title of this menu item depending on user selection. If the user has nothing selected, it should read "Export All Things…", when the user has some things selected it should read "Export Selected Things…". When no document is open, it should just read "Export Things…".
Where and when should I change this menu item? I figure I can just change the menu item using setTitle:, but how do I get a reference to the NSMenuItem?
You can implement -validateMenuItem: in the responder class that handles the menu item's action. It should return a BOOL (which indicates whether the item is enabled), but you also get a reference to the NSMenuItem as a parameter that you can use to change the title.
To decide which menu item you're dealing with, you should inspect its action, e.g.
- (BOOL)validateMenuItem:(NSMenuItem *)item {
if ([item action] == #selector(export:)) {
if (hasSelection) {
[item setTitle:NSLocalizedString(#"Export Selected Things",nil)];
} else {
[item setTitle:NSLocalizedString(#"Export All Things",nil)];
}
}
return YES;
}

Resources