How do I override drag reception in an NSTextField? - cocoa

I'm writing this in Swift. I have an NSTextField I've assigned a class in IB defined by:
class MyTextField : NSTextField, NSDraggingDestination {
I've overridden draggingEntered, draggingUpdated, prepareForDragOperation, performDragOperation in the subclass, but none of these is ever called and the system just puts stuff in the field as it sees fit. I want to handle the drag because, among other things, I don't want the default behavior of pasting a URL into the field if the user drags a file to it. Instead, if he does that, I want to get the display name of the file and use that instead.
What am I missing?

One of the responsibilities of any object implementing the <draggingDestination> protocol is to maintain an array of data-types which informs others what sort of data will trigger the methods you mention in your question. To allow your subclass to deal with drags from Finder or the desktop, I've found you need to register for three pasteboard types.
/* Sorry, not using Swift yet */
// MyNSTextField.m
- (void)awakeFromNib {
[self registerForDraggedTypes:#[NSPasteboardTypeString,
NSURLPboardType,
NSFilenamesPboardType]];
}
At least on OS X 10.9, this is sufficient to fire your draggingEntered method. If all you want on the pasteboard is the filename, rather than the full URL or path, you need to (i) extract the name, (ii) clear the pasteboard and (iii) add just the name back onto the pasteboard:
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
NSDragOperation operation = NSDragOperationNone;
NSPasteboard *pBoard = [sender draggingPasteboard];
NSArray *array = [pBoard readObjectsForClasses:#[[NSURL class], [NSString class]]
options:nil];
if ([array count] > 0) {
NSString *filename;
if ([[array firstObject] isKindOfClass:[NSURL class]]) {
// Possibly a file dragged from Finder
NSURL *url = [array firstObject];
filename = [[url pathComponents] lastObject];
} else if ([[array firstObject] isKindOfClass:[NSString class]]) {
// Possibly a file dragged from the desktop
NSString *path = [array firstObject];
BOOL isPath = [[NSFileManager defaultManager] fileExistsAtPath:path];
if (isPath) {
filename = [path lastPathComponent];
}
}
if (filename) {
[pBoard clearContents];
[pBoard setData:[filename dataUsingEncoding:NSUTF8StringEncoding]
forType:NSPasteboardTypeString];
operation = NSDragOperationGeneric;
}
}
return operation;
}
On occasion the drag into the text field will happen so quickly that the above method is not triggered, in which case you're back to the same problem. One way around this is to implement the following text field delegate method:
// From NSTextFieldDelegate Protocol
- (void)textDidChange:(NSNotification *)notification
In this method you can compare the contents of your text field, with the contents of the pasteboard, if you're field now contains a valid system path and this path matches the contents of the pasteboard, you know you need to adjust the string in the text field. Fortunately, this seems to happen so quickly that it looks just like a normal paste operation.

Related

Core Data Exporting all tables

I have an app that creates individual events and stores them in core data. What I need to do it load one individually and then export it by email. The code below works except it exports every event where I need it to just export the index path selected one. The code does load the appropriate record because the NSLog (#"My record is: %#", currentItem); does display only the settings for that event but when the data is exported to email all events are sent. I need the selected event with the event name to export. Any thoughts?
NSInteger index = exportevent.tag;
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:index inSection:0];
CDBaseItem *rawRecord = [self.fetchedResultsController objectAtIndexPath:indexPath];
CDSurveyItem *surveyItem = [CDSurveyItem castObject:rawRecord];
self.recordEditID = [rawRecord.objectID URIRepresentation];
NSManagedObjectID *objectId = [self.managedObjectContext.persistentStoreCoordinator managedObjectIDForURIRepresentation:self.recordEditID];
TSPItem *currentItem = [self.managedObjectContext objectWithID:objectId];
NSString *eventName = nil;
if (currentItem.eventname) {
eventName = currentItem.eventname;
}
else if (surveyItem.eventname) {
eventName = surveyItem.eventname;
}
[self setSelection:indexPath];
if (self.selection)
{
if (currentItem)
{
NSLog (#"My record is: %#", currentItem);
NSData *export = [CDJSONExporter exportContext:currentItem.managedObjectContext auxiliaryInfo:nil];
MFMailComposeViewController *composeVC1 = [[MFMailComposeViewController alloc] init];
composeVC1 = [[MFMailComposeViewController alloc] init];
composeVC1.mailComposeDelegate = self;
[composeVC1 setSubject:[NSString stringWithFormat:#"Settings From %# Event", eventName]];
[composeVC1 setMessageBody:[NSString stringWithFormat:#"Here is the event settings. Simply press on the attachment and then choose Open in iPIX"] isHTML:NO];
[composeVC1 addAttachmentData:export mimeType:#"application/octet-stream" fileName:[NSString stringWithFormat:#"%#.ipix", eventName]];
[self presentViewController:composeVC1 animated:NO completion:^(void){}];
}
[self setSelection:nil];
}
Your NSLog may be correct, but you're not exporting the thing that you're printing. In this line (which I assume is a reference to this project):
NSData *export = [CDJSONExporter exportContext:currentItem.managedObjectContext auxiliaryInfo:nil];
You're telling CDJSONExporter to export the context, not a single object. You get every object because that is what CDJSONExporter does. It gets everything it can find in the context and gives you a data object. It's not designed to do what you're asking it to do.
If you want to convert a single object to JSON, you could
Roll your own JSON conversion code. Since you know what the object looks like, this would be easy. Or...
Implement Encodable on your model object and then use JSONEncoder to convert to JSON. Or...
Find some other open source project that does what you want, instead of this one which does not.

NSTextFinder + programmatically changing the text in NSTextView

I have a NSTextView for which I want to use the find bar. The text is selectable, but not editable. I change the text in the text view programatically.
This setup can crash when NSTextFinder tries to select the next match after the text was changed. It seems NSTextFinder hold on to outdated ranges for incremental matches.
I tried several methods of changing the text:
[textView setString:#""];
or
NSTextStorage *newStorage = [[NSTextStorage alloc] initWithString:#""];
[textView.layoutManager replaceTextStorage:newStorage];
or
[textView.textStorage beginEditing];
[textView.textStorage setAttributedString:[[NSAttributedString alloc] initWithString:#""]];
[textView.textStorage endEditing];
Only replaceTextStorage: calls -[NSTextFinder noteClientStringWillChange]. None of the above invokes -[NSTextFinder cancelFindIndicator].
Even with NSTextFinder notified about the text change it can crash on Find Next (command-G).
I have also tried creating my own NSTextFinder instance as suggested in this post. Even though NSTextView does not implement the NSTextFinderClient protocol this works and fails just the same as without the NSTextFinder instance.
What is the correct way to use NSTextFinder with NSTextView?
I had the same problem with the text view in my app, and what makes it even more annoying is that all "solutions" you find on the internet are either incorrect or at least incomplete. So here is my contribution.
When you set textView.useFindBar = YES in a NSTextView, this text view creates a NSTextFinder internally, and forwards the search/replace commands to it. Unfortunately, NSTextView does not seem to handle correctly the changes you make programmatically to its associated NSTextStorage, which causes the crashes you mention.
If you want to change this behavior, creating your private NSTextFinder is not enough: you also need to avoid the use by the text view of its default text finder, otherwise conflicts will occur and the new text finder won't be of much use.
To do this, you have to subclass NSTextView:
#interface MyTextView : NSTextView
- (void) resetTextFinder; // A method to reset the view's text finder when you change the text storage
#end
And in your text view, you have to override the responder methods used for controlling the text finder:
#interface MyTextView () <NSTextFinderClient>
{
NSTextFinder* _textFinder; // define your own text finder
}
#property (readonly) NSTextFinder* textFinder;
#end
#implementation MyTextView
// Text finder command validation (could also be done in method validateUserInterfaceItem: if you prefer)
- (BOOL) validateMenuItem:(NSMenuItem *)menuItem
{
BOOL isValidItem = NO;
if (menuItem.action == #selector(performTextFinderAction:)) {
isValidItem = [self.textFinder validateAction:menuItem.tag];
}
// validate other menu items if needed
// ...
// and don't forget to call the superclass
else {
isValidItem = [super validateMenuItem:menuItem];
}
return isValidItem;
}
// Text Finder
- (NSTextFinder*) textFinder
{
// Create the text finder on demand
if (_textFinder == nil) {
_textFinder = [[NSTextFinder alloc] init];
_textFinder.client = self;
_textFinder.findBarContainer = [self enclosingScrollView];
_textFinder.incrementalSearchingEnabled = YES;
_textFinder.incrementalSearchingShouldDimContentView = YES;
}
return _textFinder;
}
- (void) resetTextFinder
{
if (_textFinder != nil) {
// Hide the text finder
[_textFinder cancelFindIndicator];
[_textFinder performAction:NSTextFinderActionHideFindInterface];
// Clear its client and container properties
_textFinder.client = nil;
_textFinder.findBarContainer = nil;
// And delete it
_textFinder = nil;
}
}
// This is where the commands are actually sent to the text finder
- (void) performTextFinderAction:(id<NSValidatedUserInterfaceItem>)sender
{
[self.textFinder performAction:sender.tag];
}
#end
In your text view, you still need to set properties usesFindBar and incrementalSearchingEnabled to YES.
And before changing the view's text storage (or text storage contents) you just need to call [myTextView resetTextFinder]; to recreate a brand new text finder for your new content the next time you will do a search.
If you want more information about NSTextFinder, the best doc I have seen is in the AppKit Release Notes for OS X 10.7
The solution I had come up with seems rather similar to the one offered by #jlj. In both solutions NSTextView is used as client of NSTextFinder.
It seems that the main difference is that I don't hide the find bar on text change. I also hold onto my NSTextFinder instance. To do so I need to call [textFinder noteClientStringWillChange].
Changing text:
NSTextView *textView = self.textView;
NSTextFinder *textFinder = self.textFinder;
[textFinder cancelFindIndicator];
[textFinder noteClientStringWillChange];
[textView setString:#"New text"];
The rest of the view controller code looks like this:
- (void)viewDidLoad
{
[super viewDidLoad];
NSTextFinder *textFinder = [[NSTextFinder alloc] init];
[textFinder setClient:(id < NSTextFinderClient >)textView];
[textFinder setFindBarContainer:[textView enclosingScrollView]];
[textView setUsesFindBar:YES];
[textView setIncrementalSearchingEnabled:YES];
self.textFinder = textFinder;
}
- (void)viewWillDisappear
{
NSTextFinder *textFinder = self.textFinder;
[textFinder cancelFindIndicator];
[super viewWillDisappear];
}
- (id)supplementalTargetForAction:(SEL)action sender:(id)sender
{
id target = [super supplementalTargetForAction:action sender:sender];
if (target != nil) {
return target;
}
if (action == #selector(performTextFinderAction:)) {
target = self.textView;
if (![target respondsToSelector:action]) {
target = [target supplementalTargetForAction:action sender:sender];
}
if ((target != self) && [target respondsToSelector:action]) {
return target;
}
}
return nil;
}

NSPopupButton in view based NSTableView: getting bindings to work

Problem Description
I'm trying to achieve something that should be simple and fairly common: having a bindings populated NSPopupButton inside bindings populated NSTableView. Apple describes this for a cell based table in the their documentation Implementing To-One Relationships Using Pop-Up Menus and it looks like this:
I can't get this to work for a view based table. The "Author" popup won't populate itself no matter what I do.
I have two array controllers, one for the items in the table (Items) and one for the authors (Authors), both associated with the respective entities in my core data model. I bind the NSManagedPopup in my cell as follows in interface builder:
Content -> Authors (Controller Key: arrangedObjects)
Content Values -> Authors (Controller Key: arrangedObjects, Model Key Path: name)
Selected Object -> Table Cell View (Model Key Path: objectValue.author
If I place the popup somewhere outside the table it works fine (except for the selection obviously), so I guess the binding setup should be ok.
Things I Have Already Tried
Someone suggested a workaround using an IBOutlet property to the Authors array controller but this doesn't seem to work for me either.
In another SO question it was suggested to subclass NSTableCellView and establish the required connections programmatically. I tried this but had only limited success.
If I setup the bindings as follows:
- (NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row {
NSView *view = [tableView makeViewWithIdentifier:tableColumn.identifier owner:self];
if ([tableColumn.identifier isEqualToString:#"Author") {
AuthorSelectorCell *authorSelectorCell = (AuthorSelectorCell *)view;
[authorSelectorCell.popupButton bind:NSContentBinding toObject:self.authors withKeyPath:#"arrangedObjects" options:nil];
[authorSelectorCell.popupButton bind:NSContentValuesBinding toObject:self.authors withKeyPath:#"arrangedObjects.name" options:nil];
[authorSelectorCell.popupButton bind:NSSelectedObjectBinding toObject:view withKeyPath:#"objectValue.author" options:nil];
}
return view;
}
the popup does show the list of possible authors but the current selection always shows as "No Value". If I add
[authorSelectorCell.popupButton bind:NSSelectedValueBinding toObject:view withKeyPath:#"objectValue.author.name" options:nil];
the current selection is completely empty. The only way to make the current selection show up is by setting
[authorSelectorCell.popupButton bind:NSSelectedObjectBinding toObject:view withKeyPath:#"objectValue.author.name" options:nil];
which will break as soon as I select a different author since it will try to assign an NSString* to an Author* property.
Any Ideas?
I had the same problem. I've put a sample project showing this is possible on Github.
Someone suggested a workaround using an IBOutlet property to the Authors
array controller but this doesn't seem to work for me either.
This is the approach that did work for me, and that is demonstrated in the sample project. The missing bit of the puzzle is that that IBOutlet to the array controller needs to be in the class that provides the TableView's delegate.
Had the same problem and found this workaround - basically get your authors array controller out of nib with a IBOutlet and bind to it via file owner.
You can try this FOUR + 1 settings for NSPopUpbutton:
In my example, "allPersons" is equivalent to your "Authors".
I have allPersons available as a property (NSArray*) in File's owner.
Additionally, I bound the tableView delegate to File's owner. If this is not bound, I just get a default list :Item1, Item2, Item3
I always prefer the programmatic approach. Create a category on NSTableCellView:
+(instancetype)tableCellPopUpButton:(NSPopUpButton **)popUpButton
identifier:(NSString *)identifier
arrayController:(id)arrayController
relationship:(NSString *)relationshipName
relationshipArrayController:(NSArrayController *)relationshipArrayController
relationshipAttribute:(NSString *)relationshipAttribute
relationshipAttributeIsScalar:(BOOL)relationshipAttributeIsScalar
valueTransformers:(NSDictionary *)valueTransformers
{
NSTableCellView *newInstance = [[self alloc] init];
newInstance.identifier = identifier;
NSPopUpButton *aPopUpButton = [[NSPopUpButton alloc] init];
aPopUpButton.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
[aPopUpButton bind:NSContentBinding //the collection of objects in the pop-up
toObject:relationshipArrayController
withKeyPath:#"arrangedObjects"
options:nil];
NSMutableDictionary *contentBindingOptions = [NSMutableDictionary dictionaryWithDictionary:[[TBBindingOptions class] contentBindingOptionsWithRelationshipName:relationshipName]];
NSValueTransformer *aTransformer = [valueTransformers objectForKey:NSValueTransformerNameBindingOption];
if (aTransformer) {
[contentBindingOptions setObject:aTransformer forKey:NSValueTransformerNameBindingOption];
}
[aPopUpButton bind:NSContentValuesBinding // the labels of the objects in the pop-up
toObject:relationshipArrayController
withKeyPath:[NSString stringWithFormat:#"arrangedObjects.%#", relationshipAttribute]
options:[self contentBindingOptionsWithRelationshipName:relationshipName]];
NSMutableDictionary *valueBindingOptions = [NSMutableDictionary dictionaryWithObjectsAndKeys:
[NSNumber numberWithBool:YES], NSAllowsEditingMultipleValuesSelectionBindingOption,
[NSNumber numberWithBool:YES], NSConditionallySetsEditableBindingOption,
[NSNumber numberWithBool:YES], NSCreatesSortDescriptorBindingOption,
[NSNumber numberWithBool:YES], NSRaisesForNotApplicableKeysBindingOption,
[NSNumber numberWithBool:YES], NSValidatesImmediatelyBindingOption,
nil];;
#try {
// The object that the pop-up should use as the selected item
if (relationshipAttributeIsScalar) {
[aPopUpButton bind:NSSelectedValueBinding
toObject:newInstance
withKeyPath:[NSString stringWithFormat:#"objectValue.%#", relationshipName]
options:valueBindingOptions];
} else {
[aPopUpButton bind:NSSelectedObjectBinding
toObject:newInstance
withKeyPath:[NSString stringWithFormat:#"objectValue.%#", relationshipName]
options:valueBindingOptions];
}
}
#catch (NSException *exception) {
//NSLog(#"%# %# %#", [self class], NSStringFromSelector(_cmd), exception);
}
#finally {
[newInstance addSubview:aPopUpButton];
if (popUpButton != NULL) *popUpButton = aPopUpButton;
}
return newInstance;
}
+ (NSDictionary *)contentBindingOptionsWithRelationshipName:(NSString *)relationshipNameOrEmptyString
{
NSString *nullPlaceholder;
if([relationshipNameOrEmptyString isEqualToString:#""])
nullPlaceholder = NSLocalizedString(#"(No value)", nil);
else {
NSString *formattedPlaceholder = [NSString stringWithFormat:#"(No %#)", relationshipNameOrEmptyString];
nullPlaceholder = NSLocalizedString(formattedPlaceholder,
nil);
}
return [NSDictionary dictionaryWithObjectsAndKeys:
nullPlaceholder, NSNullPlaceholderBindingOption,
[NSNumber numberWithBool:YES], NSInsertsNullPlaceholderBindingOption,
[NSNumber numberWithBool:YES], NSRaisesForNotApplicableKeysBindingOption,
nil];
}

How to print a control hierarchy in Cocoa?

Carbon had a useful function called DebugPrintControlHierarchy.
Is there something similar for NSView or NSWindow?
I don't know what exactly DebugPrintControlHierarchy printed, but NSView has a useful method call _subtreeDescription which returns a string describing the entire hierarchy beneath the receiver, include classes, frames, and other useful information.
Don't be scared about the leading _ underscore. It's not public API, but it is sanctioned for public use in gdb. You can see it mentioned in the AppKit release notes along with some sample output.
Here's the guts of an NSView category I built awhile back:
+ (NSString *)hierarchicalDescriptionOfView:(NSView *)view
level:(NSUInteger)level
{
// Ready the description string for this level
NSMutableString * builtHierarchicalString = [NSMutableString string];
// Build the tab string for the current level's indentation
NSMutableString * tabString = [NSMutableString string];
for (NSUInteger i = 0; i <= level; i++)
[tabString appendString:#"\t"];
// Get the view's title string if it has one
NSString * titleString = ([view respondsToSelector:#selector(title)]) ? [NSString stringWithFormat:#"%#", [NSString stringWithFormat:#"\"%#\" ", [(NSButton *)view title]]] : #"";
// Append our own description at this level
[builtHierarchicalString appendFormat:#"\n%#<%#: %p> %#(%li subviews)", tabString, [view className], view, titleString, [[view subviews] count]];
// Recurse for each subview ...
for (NSView * subview in [view subviews])
[builtHierarchicalString appendString:[NSView hierarchicalDescriptionOfView:subview
level:(level + 1)]];
return builtHierarchicalString;
}
- (void)logHierarchy
{
NSLog(#"%#", [NSView hierarchicalDescriptionOfView:self
level:0]);
}
Usage
Dump this into an NSView category, dump this in it. Include the category header wherever you want to use it, then call [myView logHierarchy]; and watch it go.
Swift 4.
macOS:
extension NSView {
// Prints results of internal Apple API method `_subtreeDescription` to console.
public func dump() {
Swift.print(perform(Selector(("_subtreeDescription"))))
}
}
iOS:
extension UIView {
// Prints results of internal Apple API method `recursiveDescription` to console.
public func dump() {
Swift.print(perform(Selector(("recursiveDescription"))))
}
}
Usage (in debugger): po myView.dump()

Cocoa : Refresh NSObjectController after unarchiving Data from disk

I have an Object bound to the user interface with a NSObjectController. I am able to archive the Object and unarchive it later. This works fine so far. In the Debugger I can see the object holds the data I saved in a previous session. The remaining problem is: The user interface does not refresh. I guess I have to tell the NSObjectController somehow he has to deal with an other object. But I don't know how. I tried newObject but that did not work at all.
At the moment my code looks like this:
if ([aOpenPanel runModal] == NSOKButton)
{
NSString *filename = [aOpenPanel filename];
rpgCharacter = [NSKeyedUnarchiver unarchiveObjectWithFile:filename];
// [myCharacterController DoSomething] ???
}
rpgCharacter should be the object for the myCharacterController.
What you are doing is setting the rpgCharacter iVar directly. In order to trigger KVO you need to do this in a different way either:
if you are using Objective-C 2.0 and property syntax:
if ([aOpenPanel runModal] == NSOKButton)
{
NSString *filename = [aOpenPanel filename];
self.rpgCharacter = [NSKeyedUnarchiver unarchiveObjectWithFile:filename];
}
or, if you are using KVC directly and have a correctly named setter:
if ([aOpenPanel runModal] == NSOKButton)
{
NSString *filename = [aOpenPanel filename];
[self setRpgCharacter:[NSKeyedUnarchiver unarchiveObjectWithFile:filename]];
}

Resources