How can I stop a NSSavePanel from closing after its completion block? - objective-c

If something goes wrong saving a file, I'd like to show the error alert as a sheet over the save sheet, as overwrite prompt does. However, the save panel closes immediately upon the completion of the completion block, taking the error alert with it.
[panel beginSheetModalForWindow:window
completionHandler:^(NSInteger result) {
if (result == NSFileHandlingPanelOKButton) {
NSError *error;
// Do my saving here...
if (error)
[[NSAlert alertWithError:error] beginSheetModalForWindow:panel
modalDelegate:nil
didEndSelector:nil
contextInfo:nil];
}
}];
Can I cancel hiding the NSSavePanel from within the completion block? From a delegate? From anything?

I just checked in TextEdit and what it does in the case you're after—not confirmation of the save, but failure of the save—is the following:
The Save panel rolls up.
The app tries and fails to save. (Your block.)
The app presents its error sheet on the document window, with the Save panel long gone.
So, if you want Apple-like behavior, show your alert sheet on the document window.
Incidentally, you may be interested in presentError:modalForWindow:delegate:didPresentSelector:contextInfo:.

Related

"+[CATransaction synchronize] called within transaction" with NSSavePanel

Starting with macOS Ventura (or maybe with the latest Xcode version), I'm getting log messages in my Mac app:
+[CATransaction synchronize] called within transaction
whenever the app displays an NSSavePanel (or subclass). For instance:
NSOpenPanel* panel = [NSOpenPanel openPanel];
panel.prompt = #"Import";
panel.canChooseDirectories = NO;
panel.allowsMultipleSelection = YES;
panel.message = #"Choose chromatogram files to import into the selected folder.";
panel.allowedFileTypes = #[#"com.appliedbiosystems.abif.fsa", #"com.appliedbiosystems.abif.hid"];
[panel beginSheetModalForWindow:self.parentWindow completionHandler:^(NSInteger result){
if (result == NSModalResponseOK) {
[self addSamplesFromFiles:[panel.URLs valueForKeyPath:#"#unionOfObjects.path"] toFolder:selectedFolder];
}
}];
Here, beginSheetModalForWindow triggers the warning.
If I spawn the panel with beginWithCompletionHandler (i.e., not as a sheet), the message is posted as well. In fact, it also gets posted whenever I resize the panel.
This doesn't occur with other windows or with NSAlert sheets, but this occur with all open/save panels in my app. I have checked that these panels are not used within CATransaction blocks. They are spawned from the main thread.
All panels behave normally to the user, but I'd like to know what I'm doing wrong and why this started happening.

How to indicate progress when using NSSavePanel?

My macOS application needs to export a file using NSSavePanel, and this file needs complicated progress. Now my way is
[mySavePanel beginSheetModalForWindow:self.window completionHandler:^(NSInteger result){
if (result == NSModalResponseOK) {
//complicated progress
}
}];
By this way, when I click "save" button on NSSavePanel, it takes a long time without indicating anything. I want to add something to indicate the progress, but now that NSSavePanel is a modal, so I can't let another window used to indicate progress to showWindow.

Modal NSAlert within block not displayed

In my document-based application, I have overridden the method openDocument: in my subclass of NSDocumentcontroller so that I can display my own openPanel. I pass the chosen URLs to the method openDocumentWithContentsOfURL:display:completionHandler:. I use this code for the call:
[self openDocumentWithContentsOfURL:[chosenFiles objectAtIndex:i] display:YES completionHandler:^(NSDocument *document, BOOL documentWasAlreadyOpen, NSError *error) {
if (document == nil)
{
NSAlert* alert = [NSAlert alertWithError:error];
[alert runModal];
}
}];
So I want to display the passed error if nil gets returned as a reference to the document. The problem is, that the program just "freezes" after I press the "Open" button in the open panel. Then I need to manually stop the program with the "stop" button in Xcode. No spinning beach ball appears, though. If I comment the line "[alert runModal]", the program does not freeze any more, but no alert is displayed, of course.
Now the strange thing: The code works sometimes. If I switch from Xcode to my browser and back and I run the program again, it sometimes works flawlessly and the error is displayed. After some time it stops working again. It is unpredictable, but most of the time it doesn't work.
All this sounds like a race-condition to me. It certainly has something to do with the block? But what do I do wrong?
Converting my comment to an answer:
runModel on the main thread.
[alert performSelectorOnMainThread:#selector(runModal) withObject:nil waitUntilDone:NO];
I think runModel needs to be called on the main thread because it's part of the AppKit framework, and it's estentially triggering UI graphics. I believe all calls to the AppKit framework or to any method that manipulates graphics needs to be on the main thread.

Block until NSAlert (shown as a modal sheet) is dismissed

I'm currently learning (by doing) objective-c by implementing a feature I felt is missing in the Titanium Appcelerator Desktop SDK: A way to do modal dialog with custom button texts and optionally to display them as a "sheet".
All is dandy and working, however, when displaying the NSAlert as a "sheet" my method that's creating the alert returns immediately and that's what I want to prevent.
The method's creating the alert returns an int (the return code from the NSAlert).
The code inside basically boils down to:
int returnCode = -1;
if (displayAsSheet) {
[alert beginSheetModalForWindow:nativeWindow modalDelegate:delegate didEndSelector:#selector(alertDidEnd:returnCode:contextInfo:) contextInfo:nil];
} else {
returnCode = [alert runModal];
}
return returnCode;
The modalDelegate is an object that's implementing the needed:
- (void)alertDidEnd:(NSAlert *)alert returnCode:(NSInteger)returnCode contextInfo:(void *)contextInfo;
and for now it just does a NSLog of the returnCode.
So my question is:
How can I block my method from returning until the "sheet" has been dismissed?
Or am I going about this the wrong way?
You must start a modal session for you sheet after showing it and stop the session after closing sheet.
Check this: https://github.com/incbee/NSAlert-SynchronousSheet, I think it will be helpfull.
You could use this after beginSheetModalForWindow:...:
[[NSRunLoop mainRunLoop] runMode:NSModalPanelRunLoopMode beforeDate:[NSDate distantFuture]]
However, it will make any other windows in your app unusable until the sheet is dismissed. It would be better not to block those windows.
my method that's creating the alert returns immediately
I believe that's because, as #Josh says, the sheet is running modal only relative to the window to which it is attached; it is not freezing the entire app. Therefore, as soon as beginSheetModal... executes, the rest of your method continues to run, concluding with return returnCode (here returning -1), without waiting for the user to respond to the alert.
The return code is a stand-in for which button on the alert panel the user ends up pushing (NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, etc. -- they're listed at the end of the NSAlert class ref). You use it in your alertDidEnd method to act upon whichever button the user pushed to dismiss the alert. That's why the alertDidEnd selector includes the returnCode.
On the other hand, when you use the runModal method in your else block, you need to explicitly call alertDidEnd and feed it the number returned when the runModal method ends -- which is when the user dismisses the alert.
Here's a revised version of your code:
int returnCode = -1;
if (displayAsSheet) {
[alert beginSheetModalForWindow:nativeWindow modalDelegate:delegate didEndSelector:#selector(alertDidEnd:returnCode:contextInfo:) contextInfo:nil];
// The selector alertDidEnd has the returnCode int. The alert will then set that code to whatever the user chooses, and will send the altered int on to alertDidEnd.
}
else {
// Here, everything stops once runModal is called, until the user dismisses the alert, at which time the runModal method returns the int representing the button the user pushed, and you assign the return to your variable "returnCode."
returnCode = [alert runModal];
[self alertDidEnd:alert returnCode:returnCode contextInfo:nil];
}
// Omit the line returning the returnCode.
Then the alertDidEnd method does something like this:
- (void)alertDidEnd:(NSAlert *)alert returnCode:(NSInteger)returnCode contextInfo:(void *)contextInfo {
switch (returnCode) {
case NSAlertFirstButtonReturn:
// Do whatever should happen when first button is pushed.
break;
case NSAlertSecondButtonReturn:
// Do whatever should happen when second button is pushed.
break;
default:
break;
}
// Unfreeze things.
[[NSApplication sharedApplication] stopModal];
}
By the way, there is a way of running a sheet and freezing the entire app, not just the window to which the sheet is attached, if that's what you want: modal tips
You're thinking about this slightly the wrong way. If your method were able to wait for the sheet to end, the app's event loop would be blocked and there would be no way for the user to interact with the UI. When you use runModal for the alert, a new run loop is created to handle the alert -- this is why nothing else can be done to the app. The point of the sheet option is to allow the user to do other things while the alert is displayed -- i.e., it expressly does not take over the event handling.
You could look into faking the sheet by attaching a child window.
You could try to set a boolean that freezes anything you want to freeze on your app (set freeze = YES) until the sheet is removed (set freeze = NO).
In the general case, you don't need to block a method : you just want some things not to happen until the user has made a choice.
For example, I have an app that uses gyroscope. It has some behaviour with, and some other behaviour without.
So I have a boolean that is used in any method that uses the gyro data to route the behaviour to the good one. My useGyro Boolean is NO when : the user is choosing what kind of hardware feature it want to enable or not, and when gyro is not available on the device.
The same thing with a mapView I have : when the user is aked by the system if it wants to be located, there is a moment where I freeze any behaviour using the user location. When he has made his choice, I change that bool value.

Cocoa: Processing thread results, and queuing multiple sheets

I have a multithreaded application that has many concurrent operations going on at once. When each thread is finished it calls one of two methods on the main thread
performSelectorOnMainThread:#selector(operationDidFinish:)
// and
performSelectorOnMainThread:#selector(operationDidFail:withMessage:)
When an operation fails, I launch a sheet that displays the error message and present the user with 2 buttons, "cancel" and "try again". Here is the code I use to launch the sheet:
// failureSheet is a NSWindowController subclass
[NSApp beginSheet:[failureSheet window]
modalForWindow:window
modalDelegate:self
didEndSelector:#selector(failureSheetDidEnd:returnCode:contextInfo:)
contextInfo:nil];
The problem is that if 2 concurrent operations fail at the same time, then the current sheet that is displayed gets overwritten with the last failure message, and then the user's "try again" action will only retry the last failed operation. Ideally, I would like to "queue" these failure sheets. If 2 operations fail at the same time then you should see 2 sheets one right after the other, allowing the user to cancel or retry them individually.
I've tried using:
[NSApp runModalSessionForWindow:[failureSheet window]]
which seems to do what I want, but doesn't work in my situation. Maybe it isn't thread safe?
For example the following code works...
- (void)displaySheet
{
[NSApp beginSheet:[failureSheet window]
modalForWindow:window
modalDelegate:self
didEndSelector:#selector(failureSheetDidEnd:returnCode:contextInfo:)
contextInfo:nil];
[NSApp runModalForWindow:[failureSheet window]];
[NSApp endSheet:[failureSheet window]];
[[failureSheet window] orderOut:nil];
}
// Calling this method from a button press works...
- (IBAction)testDisplayTwoSheets
{
[self displaySheet];
[self displaySheet];
}
However if I have 2 different threaded operations invoke displaySheet (on the main thread) when they are done, I only see one sheet and when I close it the modal session is still running and my app is essentially stuck.
Any suggestions as to what I'm doing wrong?
If you want to queue them, then just queue them. Create an NSMutableArray of result objects (you could use the operation, or the failure sheet itself, or a data object that gives you the information for the sheet; whatever is convenient). In operationDidFinish: (which always runs on the main thread, so no locking issues here), you'd do something like this:
[self.failures addObject:failure];
if ([[self window] attachedSheet] == nil)
{
// Only start showing sheets if one isn't currently being shown.
[self displayNextFailure];
}
Then you'd have:
- (void)displayNextFailure
{
if ([self.failures count] > 0)
{
MYFailure runFailure = [self.failures objectAtIndex:0];
[self.failures removeObjectAtIndex:0];
[displaySheetForFailure:failure];
}
}
And at the end of failureSheetDidEnd:returnCode:contextInfo:, just make sure to call [self displayNextFailure].
That said, this is probably a horrible UI if it can happen often (few things are worse than displaying sheet after sheet). I'd probably look for ways to modify the existing sheet to display multiple errors.
I don't think you're using the "runModalForWindow:" command properly. You wouldn't use both [NSApp beginSheet...] and "runModalForWindow:" at the same time. One is for document modal (where the application keeps running but the window with the sheet is locked) and one is for application modal (which stops everything in the entire app until the sheet is dismissed). You only want the application modal dialog so do this...
For application modal start the window with the following only:
[[failureSheet window] center];
[NSApp runModalForWindow:[failureSheet window]];
Hook the "OK" button and others to regular IBAction methods. They'll get called when a button is pressed. In the IBAction methods you need to do something like this to dismiss the window and process the action:
-(IBAction)okBtnPressed:(id)sender {
[NSApp stopModal];
NSLog(#"ok button");
[[sender window] close];
}