Show NSSegmentedControl menu when segment clicked, despite having set action - cocoa

I have an NSSegmentedControl on my UI with 4 buttons. The control is connected to a method that will call different methods depending on which segment is clicked:
- (IBAction)performActionFromClick:(id)sender {
NSInteger selectedSegment = [sender selectedSegment];
NSInteger clickedSegmentTag = [[sender cell] tagForSegment:selectedSegment];
switch (clickedSegmentTag) {
case 0: [self showNewEventWindow:nil]; break;
case 1: [self showNewTaskWindow:nil]; break;
case 2: [self toggleTaskSplitView:nil]; break;
case 3: [self showGearMenu]; break;
}
}
Segment 4 has has a menu attached to it in the awakeFromNib method. I'd like this menu to drop down when the user clicks the segment. At this point, it only will drop if the user clicks & holds down on the menu. From my research online this is because of the connected action.
I'm presently working around it by using some code to get the origin point of the segment control and popping up the context menu using NSMenu's popUpContextMenu:withEvent:forView but this is pretty hacktastic and looks bad compared to the standard behavior of having the menu drop down below the segmented control cell.
Is there a way I can have the menu drop down as it should after a single click rather than doing the hacky context menu thing?

Subclass NSSegmentedCell, override method below, and replace the cell class in IB. (Requires no private APIs).
- (SEL)action
{
//this allows connected menu to popup instantly (because no action is returned for menu button)
if ([self tagForSegment:[self selectedSegment]]==0) {
return nil;
} else {
return [super action];
}
}

I'm not sure of any built-in way to do this (though it really is a glaring hole in the NSSegmentedControl API).
My recommendation is to continue doing what you're doing popping up the context menu. However, instead of just using the segmented control's origin, you could position it directly under the segment (like you want) by doing the following:
NSPoint menuOrigin = [segmentedControl frame].origin;
menuOrigin.x = NSMaxX([segmentedControl frame]) - [segmentedControl widthForSegment:4];
// Use menuOrigin where you _were_ just using [segmentedControl frame].origin
It's not perfect or ideal, but it should get the job done and give the appearance/behavior your users expect.
(as an aside, NSSegmentedControl really needs a -rectForSegment: method)

This is the Swift version of the answer by J Hoover and the mod by Adam Treble. The override was not as intuitive as I thought it would be, so this will hopefully help someone else.
override var action : Selector {
get {
if self.menuForSegment(self.selectedSegment) != nil {
return nil
}
return super.action
}
set {
super.action = newValue
}
}

widthForSegment: returns zero if the segment auto-sizes. If you're not concerned about undocumented APIs, there is a rectForSegment:
(NSRect)rectForSegment:(NSInteger)segment
inFrame:(NSRect)frame;
But to answer the original question, an easier way to get the menu to pop up immediately is to subclass NSSegmentedCell and return 0 for (again, undocumented)
(double)_menuDelayTimeForSegment:(NSInteger)segment;

Related

Automatically wrap NSTextField using Auto Layout

How does one go about having auto-layout automatically wrap an NSTextField to multiple lines as the width of the NSTextField changes?
I have numerous NSTextFields displaying static text (i.e.: labels) in an inspector pane. As the inspector pane is resized by the user, I would like the right hand side labels to reflow to multiple lines if need be.
(The Finder's Get Info panel does this.)
But I haven't been able to figure out the proper combination of auto layout constraints to allow this behavior. In all case, the NSTextFields on the right refuse to wrap. (Unless I explicitly add a height constraint that would allow it to.)
The view hierarchy is such that each gray band is a view containing two NSTextFields, the property name on the left and the property value on the right. As the user resizes the inspector pane, I would like the property value label to auto-resize it's height as need-be.
Current situation:
What I would like to have happen:
(Note that this behavior is different than most Stack Overflow questions I came across regarding NSTextFields and auto layout. Those questions wanted the text field to grow while the user is typing. In this situation, the text is static and the NSTextField is configured to look like a label.)
Update 1.0
Taking #hamstergene's suggestion, I subclassed NSTextField and made a little sample application. For the most part, it now works, but there's now a small layout issue that I suspect is a result of the NSTextField's frame not being entirely in sync with what auto-layout expects it to be. In the screenshot below, the right-hand side labels are all vertically spaced with a top constraint. As the window is resized, the Where field is getting properly resized and wrapped. However, the Kind text field does not get pushed down until I resize the window "one more pixel".
Example: If I resize the window to just the right width that the Where textfield does it's first wrap, then I get the results in the middle image. If I resize the window one more pixel, then the Kind field's vertical location is properly set.
I suspect that's because auto-layout is doing it's pass and then the frames are getting explicitly set. I imagine auto-layout doesn't see that on that pass but does it it on the next pass, and updates the positions accordingly.
Assuming that's the issue, how do I inform auto-layout of these changes I'm doing in setFrameSize so that it can run the layout again. (And, most importantly, not get caught in recursive state of layout-setFrameSize-layout-etc...)
Solution
I've come up with a solution that appears to work exactly how I was hoping. Instead of subclassing NSTextField, I just override layout in the superview of the NSTextField in question. Within layout, I set the preferredMaxLayoutWidth on the text field and then trigger a layout pass. That appears to be enough to get it mostly working, but it leaves the annoying issue of the layout being briefly "wrong". (See note above).
The solution to that appears to be to call setNeedsDisplay and then everything Just Works.
- (void)layout {
NSTextField *textField = ...;
NSRect oldTextFieldFrame = textField.frame;
[textField setPreferredMaxLayoutWidth:NSWidth(self.bounds) - NSMinX(textField.frame) - 12.0];
[super layout];
NSRect newTextFieldFrame = textField.frame;
if (oldTextFieldFrame.size.height != newTextFieldFrame.size.height) {
[self setNeedsDisplay:YES];
}
}
The simplest way to get this working, assuming you're using an NSViewController-based solution is this:
- (void)viewDidLayout {
[super viewDidLayout];
self.aTextField.preferredMaxLayoutWidth = self.aTextField.frame.size.width;
[self.view layoutSubtreeIfNeeded];
}
This simply lets the constraint system solve for the width (height will be unsolvable on this run so will be what ever you initially set it to), then you apply that width as the max layout width and do another constraint based layout pass.
No subclassing, no mucking with a view's layout methods, no notifications. If you aren't using NSViewController you can tweak this solution so that it works in most cases (subclassing textfield, in a custom view, etc.).
Most of this came from the swell http://www.objc.io/issue-3/advanced-auto-layout-toolbox.html (look at the Intrinsic Content Size of Multi-Line Text section).
If inspector pane width will never change, just check "First Runtime Layout Width" in IB (note it's 10.8+ feature).
But allowing inspector to have variable width at the same time is not possible to achieve with constraints alone. There is a weak point somewhere in AutoLayout regarding this.
I was able to achieve reliable behaviour by subclassing the text field like this:
- (NSSize) intrinsicContentSize;
{
const CGFloat magic = -4;
NSSize rv;
if ([[self cell] wraps] && self.frame.size.height > 1)
rv = [[self cell] cellSizeForBounds:NSMakeRect(0, 0, self.bounds.size.width + magic, 20000)];
else
rv = [super intrinsicContentSize];
return rv;
}
- (void) layout;
{
[super layout];
[self invalidateWordWrappedContentSizeIfNeeded];
}
- (void) setFrameSize:(NSSize)newSize;
{
[super setFrameSize:newSize];
[self invalidateWordWrappedContentSizeIfNeeded];
}
- (void) invalidateWordWrappedContentSizeIfNeeded;
{
NSSize a = m_previousIntrinsicContentSize;
NSSize b = self.intrinsicContentSize;
if (!NSEqualSizes(a, b))
{
[self invalidateIntrinsicContentSize];
}
m_previousIntrinsicContentSize = b;
}
In either case, the constraints must be set the obvious way (you have probably already tried it): high vertical hugging priority, low horizontal, pin all four edges to superview and/or sibling views.
Set in the size inspector tab in section Text Field Preferred Width to "First Runtime layout Width"
This works for me and is a bit more elegant. Additionally i've made a little sample project on Github
public class DynamicTextField: NSTextField {
public override var intrinsicContentSize: NSSize {
if cell!.wraps {
let fictionalBounds = NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width, height: CGFloat.greatestFiniteMagnitude)
return cell!.cellSize(forBounds: fictionalBounds)
} else {
return super.intrinsicContentSize
}
}
public override func textDidChange(_ notification: Notification) {
super.textDidChange(notification)
if cell!.wraps {
validatingEditing()
invalidateIntrinsicContentSize()
}
}
}

Change NSTextField's behavior for multiple clicks in a row

I have a NSTextField which is nested by a custom view and I want to change the default behavior of multiple clicks in a row (double click, tripple click etc.), similarly to the behavior of text nodes MindNode (see the image below).
I want the first click to "activate" the text field and then go on from the beginning (like reseting the click count of the event).
I have following ideas, but I don't know how to implement them and if they actually make sense:
Somehow change the time using +[NSEvent doubleClickInterval] and slow down the second click.
Reduce the click count programmatically?
Make the NSTextField non-selectable using -hitTest:, forward the click to the superview, change some parameter of the text field and accept the next clicks. In this case, the click count of the second click is still 2.
Override -mouseDown: and not call super. This breaks the NSTextField's selection functionality.
I hope there is an easier way to achieve this, which I have overlooked.
Thanks for your answers!
Here is a graphical representation of the problem:
I would do this by embedding the text field and a custom view in an NSBox, which would be set to the custom type, initially with no background color or border (so it would be invisible). Initially, the custom view would be on top and it would have a mouseDown: method that would receive the first click. In that method you could rearrange the box's subviews so that the text field would then be on top and receive the next clicks. If you wanted, the box could be somewhat bigger than the text field so you could give it a background color or other drawing that would look like a custom activation ring around the text field. In the text field's controlTextDidEndEditing: method, you could reset the system back to the beginning state, so it would be ready for the next time you click on it.
After Edit: Here is the code I'm using in my overlay class:
#implementation Overlay
static NSComparisonResult rdComparator( NSView *view1, NSView *view2, void *context ) {
if ([view1 isKindOfClass:[NSTextField class]])
return NSOrderedDescending;
else if ([view2 isKindOfClass:[NSTextField class]])
return NSOrderedAscending;
return NSOrderedSame;
}
-(void)mouseDown:(NSEvent *)theEvent {
self.box.fillColor = [NSColor redColor];
NSView *contentView = self.box.subviews.lastObject;
[contentView sortSubviewsUsingFunction:rdComparator context:nil];
}
I've solved it by subclassing NSTextField and decrementing click count of mouse down events programmatically. Using a boolean property of the subclass I am able to turn this special behavior on and off.
- (void)mouseDown:(NSEvent *)theEvent
{
if (self.specialBehavior) {
theEvent = [NSEvent mouseEventWithType:theEvent.type
location:theEvent.locationInWindow
modifierFlags:theEvent.modifierFlags
timestamp:theEvent.timestamp
windowNumber:theEvent.windowNumber
context:theEvent.context
eventNumber:theEvent.eventNumber
clickCount:theEvent.clickCount - 1
pressure:theEvent.pressure];
}
[super mouseDown:theEvent];
}
To simplify this long method call, I wrote a category method for NSEvent which decrements the click count of an event.

Overriding "Edited" in window title for NSDocument

How do I prevent a window title from displaying "Edited" for an NSDocument which is dirty?
I'm managing saving and autosaving myself, using a web service, and just don't want the distraction in the title bar.
I've tried overriding:
NSDocument's -isDocumentEdited and -hasUnautosavedChanges always to return NO.
-[NSWindowController setDocumentEdited] to do nothing, or always to use NO regardless of the parameter's actual value.
-[NSWindowController synchronizeWindowTitleWithDocumentName] to do nothing.
-[NSWindow setDocumentEdited] to do nothing, or always to use NO regardless of the parameter's actual value.
In all cases, the title bar still changes to Edited when I make changes to a saved document.
If I override -[NSDocument updateChangeCount:] and -[NSDocument updateChangeCountWithToken:forSaveOperation:] to do nothing, I can prevent this from happening, but it affects saving, autosaving, and other document behaviors, too.
I also tried this:
[[self.window standardWindowButton: NSWindowDocumentVersionsButton] setTitle:nil];
That displayed a blank string instead of Edited, but the dash still appeared – the one which normally separates the document name and Edited.
Any idea how to pry apart this part of the window from the document?
Several options:
To get a pointer to the "dash", look for a TextField in [window.contentView.superview.subviews] with a stringValue equals to "-". You can set its text to an empty string as well.
#implementation NSWindow (DashRetrivalMethod)
- (NSTextField*)versionsDashTextField
{
NSTextField* res = nil;
NSView* themeFrame = [self.contentView superview];
for (NSView* tmp in [themeFrame subviews])
{
if ([tmp isKindOfClass:[NSTextField class]])
{
if ([[(NSTextField*)tmp stringValue] isEqualToString:#"—"])
{
res = (NSTextField*)tmp;
break;
}
}
}
return res;
}
#end
You can override NSWindow's -setRepresentedURL:. This would also affect the NSWindowDocumentIconButton and the popup menu, but you can manually create it if you want by: [NSWindow standardWindowButton: NSWindowDocumentIconButton].
Override one of these three NSDocument's undocumented methods:
// Always return here NO if you don't want the version button to appear.
// This seems to be the cleanest options, besides the fact that you are
/// overriding a private method.
- (BOOL)_shouldShowAutosaveButtonForWindow:(NSWindow*)window;
// Call super with NO
- (void)_setShowAutosaveButton:(BOOL)flag;
// Here the button and the dash are actually created
- (void)_endVersionsButtonUpdates;
// Here Cocoa hide or unhide the edited button
- (void)_updateDocumentEditedAndAnimate:(BOOL)flag
Have you tried overriding NSDocuments - (BOOL)hasUnautosavedChanges in addition to overriding - (BOOL) isDocumentEdited?
Although this is a late answer, you can easily determine what is going to be the title of your NSDocument window by overriding
- (NSString *)windowTitleForDocumentDisplayName:(NSString *)displayName
in your NSWindowController and return the appropriate title.
You can do that also by overriding the property of your NSDocument:
- (NSString *)displayName
but this is not recommended by Apple, because that is normally used by the OS error handlers.
I added this answer, because none of the other answers really set me on the right path.

NSButton subclass that responds to right clicks

I have an NSButton subclass that I would like to make work with right mouse button clicks. Just overloading -rightMouseDown: won't cut it, as I would like the same kind of behaviour as for regular clicks (e.g. the button is pushed down, the user can cancel by leaving the button, the action is sent when the mouse is released, etc.).
What I have tried so far is overloading -rightMouse{Down,Up,Dragged}, changing the events to indicate the left mouse button clicks and then sending it to -mouse{Down,Up,Dragged}. Now this would clearly be a hack at best, and as it turns out Mac OS X did not like it all. I can click the button, but upon release, the button remains pushed in.
I could mimic the behaviour myself, which shouldn't be too complicated. However, I don't know how to make the button look pushed in.
Before you say "Don't! It's an unconventional Mac OS X behaviour and should be avoided": I have considered this and a right click could vastly improve the workflow. Basically the button cycles through 4 states, and I would like a right click to make it cycle in reverse. It's not an essential feature, but it would be nice. If you still feel like saying "Don't!", then let me know your thoughts. I appreciate it!
Thanks!
EDIT: This was my attempt of changing the event (you can't change the type, so I made a new one, copying all information across. I mean, I know this is the framework clearly telling me Don't Do This, but I gave it a go, as you do):
// I've contracted all three for brevity
- (void)rightMouse{Down,Up,Dragging}:(NSEvent *)theEvent {
NSEvent *event = [NSEvent mouseEventWithType:NSLeftMouse{Down,Up,Dragging} location:[theEvent locationInWindow] modifierFlags:[theEvent modifierFlags] timestamp:[theEvent timestamp] windowNumber:[theEvent windowNumber] context:[theEvent context] eventNumber:[theEvent eventNumber] clickCount:[theEvent clickCount] pressure:[theEvent pressure]];
[self mouse{Down,Up,Dragging}:event];
}
UPDATE: I noticed that -mouseUp: was never sent to NSButton, and if I changed it to an NSControl, it was. I couldn't figure out why this was, until Francis McGrew pointed out that it contains its own event handling loop. Now, this also made sense to why before I could reroute the -rightMouseDown:, but the button wouldn't go up on release. This is because it was fetching new events on its own, that I couldn't intercept and convert from right to left mouse button events.
NSButton is entering a mouse tracking loop. To change this you will have to subclass NSButton and create your own custom tracking loop. Try this code:
- (void) rightMouseDown:(NSEvent *)theEvent
{
NSEvent *newEvent = theEvent;
BOOL mouseInBounds = NO;
while (YES)
{
mouseInBounds = NSPointInRect([newEvent locationInWindow], [self convertRect:[self frame] fromView:nil]);
[self highlight:mouseInBounds];
newEvent = [[self window] nextEventMatchingMask:NSRightMouseDraggedMask | NSRightMouseUpMask];
if (NSRightMouseUp == [newEvent type])
{
break;
}
}
if (mouseInBounds) [self performClick:nil];
}
This is how I do it; Hopefully it will work for you.
I've turned a left mouse click-and-hold into a fake right mouse down on a path control. I'm not sure this will solve all your problems, but I found that the key difference when I did this was changing the timestamp:
NSEvent *event = [NSEvent mouseEventWithType:NSLeftMouseDown
location:[theEvent locationInWindow]
modifierFlags:[theEvent modifierFlags]
timestamp:CFAbsoluteGetTimeCurrent()
windowNumber:[theEvent windowNumber]
context:[theEvent context]
// I was surprised to find eventNumber didn't seem to need to be faked
eventNumber:[theEvent eventNumber]
clickCount:[theEvent clickCount]
pressure:[theEvent pressure]];
The other thing is that depending on your button type, its state may be the value that is making it appear pushed or not, so you might trying poking at that.
UPDATE: I think I've figured out why rightMouseUp: never gets called. Per the -[NSControl mouseDown:] docs, the button starts tracking the mouse when it gets a mouseDown event, and it doesn't stop tracking until it gets mouseUp. While it's tracking, it can't do anything else. I just tried, for example, at the end of a custom mouseDown::
[self performSelector:#selector(mouseUp:) withObject:myFakeMouseUpEvent afterDelay:1.0];
but this gets put off until a normal mouseUp: gets triggered some other way. So, if you've clicked the right mouse button, you can't (with the mouse) send a leftMouseUp, thus the button is still tracking, and won't accept a rightMouseUp event. I still don't know what the solution is, but I figured that would be useful information.
Not much to add to the answers above, but for those working in Swift, you may have trouble finding the constants for the event mask, buried deep in the documentation, and still more trouble finding a way to combine (OR) them in a way that the compiler accepts, so this may save you some time. Is there a neater way? This goes in your subclass -
var rightAction: Selector = nil
// add a new property, by analogy with action property
override func rightMouseDown(var theEvent: NSEvent!) {
var newEvent: NSEvent!
let maskUp = NSEventMask.RightMouseUpMask.rawValue
let maskDragged = NSEventMask.RightMouseDraggedMask.rawValue
let mask = Int( maskUp | maskDragged ) // cast from UInt
do {
newEvent = window!.nextEventMatchingMask(mask)
}
while newEvent.type == .RightMouseDragged
My loop has become a do..while, as it always has to execute at least once, and I never liked writing while true, and I don't need to do anything with the dragging events.
I had endless trouble getting meaningful results from convertRect(), perhaps because my controls were embedded in a table view. Thanks to Gustav Larsson above for my ending up with this for the last part -
let whereUp = newEvent.locationInWindow
let p = convertPoint(whereUp, fromView: nil)
let mouseInBounds = NSMouseInRect(p, bounds, flipped) // bounds, not frame
if mouseInBounds {
sendAction(rightAction, to: target)
// assuming rightAction and target have been allocated
}
}

Distinguishing a single click from a double click in Cocoa on the Mac

I have a custom NSView (it's one of many and they all live inside an NSCollectionView — I don't think that's relevant, but who knows). When I click the view, I want it to change its selection state (and redraw itself accordingly); when I double-click the view, I want it to pop up a larger preview window for the object that was just double-clicked.
My first looked like this:
- (void)mouseUp: (NSEvent *)theEvent {
if ([theEvent clickCount] == 1) [model setIsSelected: ![model isSelected]];
else if ([theEvent clickCount] == 2) if ([model hasBeenDownloaded]) [mainWindowController showPreviewWindowForPicture:model];
}
which mostly worked fine. Except, when I double-click the view, the selection state changes and the window pops up. This is not exactly what I want.
It seems like I have two options. I can either revert the selection state when responding to a double-click (undoing the errant single-click) or I can finagle some sort of NSTimer solution to build in a delay before responding to the single click. In other words, I can make sure that a second click is not forthcoming before changing the selection state.
This seemed more elegant, so it was the approach I took at first. The only real guidance I found from Google was on an unnamed site with a hyphen in its name. This approach mostly works with one big caveat.
The outstanding question is "How long should my NSTimer wait?". The unnamed site suggests using the Carbon function GetDblTime(). Aside from being unusable in 64-bit apps, the only documentation I can find for it says that it's returning clock-ticks. And I don't know how to convert those into seconds for NSTimer.
So what's the "correct" answer here? Fumble around with GetDblTime()? "Undo" the selection on a double-click? I can't figure out the Cocoa-idiomatic approach.
Delaying the changing of the selection state is (from what I've seen) the recommended way of doing this.
It's pretty simple to implement:
- (void)mouseUp:(NSEvent *)theEvent
{
if([theEvent clickCount] == 1) {
[model performSelector:#selector(toggleSelectedState) afterDelay:[NSEvent doubleClickInterval]];
}
else if([theEvent clickCount] == 2)
{
if([model hasBeenDownloaded])
{
[NSRunLoop cancelPreviousPerformRequestsWithTarget: model];
[mainWindowController showPreviewWindowForPicture:model];
}
}
}
(Notice that in 10.6, the double click interval is accessible as a class method on NSEvent)
If your single-click and double-click operations are really separate and unrelated, you need to use a timer on the first click and wait to see if a double-click is going to happen. That is true on any platform.
But that introduces an awkward delay in your single-click operation that users typically don't like. So you don't see that approach used very often.
A better approach is to have your single-click and double-click operations be related and complementary. For example, if you single-click an icon in Finder it is selected (immediately), and if you double-click an icon it is selected and opened (immediately). That is the behavior you should aim for.
In other words, the consequences of a single-click should be related to your double-click command. That way, you can deal with the effects of the single-click in your double-click handler without having to resort to using a timer.
Personally, I think you need to ask yourself why you want this non-standard behaviour.
Can you point to any other application which treats the first click in a double-click as being different from a single-click? I can't think of any...
Add two properties to your custom view.
// CustomView.h
#interface CustomView : NSView {
#protected
id m_target;
SEL m_doubleAction;
}
#property (readwrite) id target;
#property (readwrite) SEL doubleAction;
#end
Overwrite the mouseUp: method in your custom view.
// CustomView.m
#pragma mark - MouseEvents
- (void)mouseUp:(NSEvent*)event {
if (event.clickCount == 2) {
if (m_target && m_doubleAction && [m_target respondsToSelector:m_doubleAction]) {
[m_target performSelector:m_doubleAction];
}
}
}
Register your controller as the target with an doubleAction.
// CustomController.m
- (id)init {
self = [super init];
if (self) {
// Register self for double click events.
[(CustomView*)m_myView setTarget:self];
[(CustomView*)m_myView setDoubleAction:#selector(doubleClicked:)];
}
return self;
}
Implement what should be done when a double click happens.
// CustomController.m
- (void)doubleClicked:(id)sender {
// DO SOMETHING.
}
#Dave DeLong's solution in Swift 4.2 (Xcode 10, macOS 10.13), amended for use with event.location(in: view)
var singleClickPoint: CGPoint?
override func mouseDown(with event: NSEvent) {
singleClickPoint = event.location(in: self)
perform(#selector(GameScene.singleClickAction), with: nil, afterDelay: NSEvent.doubleClickInterval)
if event.clickCount == 2 {
RunLoop.cancelPreviousPerformRequests(withTarget: self)
singleClickPoint = nil
//do whatever you want on double-click
}
}
#objc func singleClickAction(){
guard let singleClickPoint = singleClickPoint else {return}
//do whatever you want on single-click
}
The reason I'm not using singleClickAction(at point: CGPoint) and calling it with: event.location(in: self) is that any point I pass in - including CGPoint.zero - ends up arriving in the singleClick Action as (0.0, 9.223372036854776e+18). I will be filing a radar for that, but for now, bypassing perform is the way to go. (Other objects seem to work just fine, but CGPoints do not.)

Resources