Watch, Follow, &
Connect with Us

For forums, blogs and more please visit our
Developer Tools Community.


Welcome, Guest
Guest Settings
Help

Thread: Blocking TSaveDialog in OS X



Permlink Replies: 6 - Last Post: Jun 26, 2014 8:17 AM Last Post By: Michael B Threads: [ Previous | Next ]
Michael B

Posts: 5
Registered: 11/23/01
Blocking TSaveDialog in OS X
Click to report abuse...   Click to reply to this thread Reply
  Posted: Jun 23, 2014 7:47 AM
Hi,

I'm working on a FMX application for OS X. I need to implement file saving
functionality, for which I use TSaveDialog. The problem is that my
application must work in the background (timers, async sockets) while the
user is selecting a file name and location in the dialog. This is not a
problem under Windows, where displaying this dialog doesn't block the main
application thread. But under OS X, the main thread is completely blocked.

Steps to reproduce: drop a TLabel, TTimer, and TSaveDialog on a form. Add
the event handlers:

procedure TForm1.Button1Click(Sender: TObject);
begin
SaveDialog1.Execute;
end;

procedure TForm1.Timer1Timer(Sender: TObject);
begin
label1.Text:= TimeToStr(Now);
end;

Run the application under OS X. The text label is updated every second until
you click Button1. At that point it is frozen until the dialog is closed. Do
the same under Windows and the label will be updated every second even when
the dialog is active.

I understand that this is a documented "feature", and that the blocking call
is:

outcome := SaveFile.runModal; //in FMX.Platform.Mac.

The question is: how do I change that horrible behavior? I've tried to
figure out how to use beginWithCompletionHandler and
beginSheetModalForWindow, but I failed. And I'm not sure that was the right
direction anyway. And yes, I'm a beginner in programming for OS X, although
a pretty experienced Windows programmer.

Any help would be much appreciated.

Michael
Remy Lebeau (Te...


Posts: 9,447
Registered: 12/23/01
Re: Blocking TSaveDialog in OS X
Click to report abuse...   Click to reply to this thread Reply
  Posted: Jun 23, 2014 9:53 AM   in response to: Michael B in response to: Michael B
Michael wrote:

The problem is that my application must work in the background (timers,
async sockets) while the user is selecting a file name and location in
the dialog.

You need to move your background tasks into worker threads.

procedure TForm1.Button1Click(Sender: TObject);
begin
SaveDialog1.Execute;
end;

Did you read the documentation about ShowModal() usage on mobile platforms?

ShowModal Dialogs in FireMonkey Mobile Apps
http://docwiki.embarcadero.com/RADStudio/XE6/en/ShowModal_Dialogs_in_FireMonkey_Mobile_Apps

Run the application under OS X. The text label is updated every second
until you click Button1. At that point it is frozen until the dialog
is closed. Do the same under Windows and the label will be updated
every second even when the dialog is active.

Only due to an implementation detail that modal dialogs on Windows run in
an internal message loop. That is not a guarantee on non-Windows platforms,
so don't rely on it anymore.

--
Remy Lebeau (TeamB)
Michael B

Posts: 5
Registered: 11/23/01
Re: Blocking TSaveDialog in OS X
Click to report abuse...   Click to reply to this thread Reply
  Posted: Jun 23, 2014 10:50 AM   in response to: Remy Lebeau (Te... in response to: Remy Lebeau (Te...
Remy Lebeau (TeamB) wrote:
Michael wrote:

The problem is that my application must work in the background (timers,
async sockets) while the user is selecting a file name and location in
the dialog.

You need to move your background tasks into worker threads.

Thanks for your reply. There are two problems with that approach:

1. That would be rather difficult from the architectural standpoint. The application uses a few times on the main form and, like I said, asynchronous sockets that rely on the Windows messaging mechanism. Moving all of the above into additional threads would involve a major code re-write and a major debugging job, because it's obvious that certain things would definitely go wrong when removed from the main thread.

2. I don't see how this will help me, as all Synchronize calls in my code on OS X would be blocked as long as the save dialog is active. I'd have to refrain from calling Synchronize() while the save dialog is shown, and that's something I don't want to do. For the sake of simplicity, imagine an application that shows the current time on a clock. Even if you move your internal timer into a worker thread, you can't have your GUI show the current time if the user clicked "Save As..." and the dialog is active.


procedure TForm1.Button1Click(Sender: TObject);
begin
SaveDialog1.Execute;
end;

Did you read the documentation about ShowModal() usage on mobile platforms?

ShowModal Dialogs in FireMonkey Mobile Apps
http://docwiki.embarcadero.com/RADStudio/XE6/en/ShowModal_Dialogs_in_FireMonkey_Mobile_Apps

I did, but I'm developing a desktop app, rather than a mobile one, and the examples on that page don't deal with the thread blocking issue.

Run the application under OS X. The text label is updated every second
until you click Button1. At that point it is frozen until the dialog
is closed. Do the same under Windows and the label will be updated
every second even when the dialog is active.

Only due to an implementation detail that modal dialogs on Windows run in
an internal message loop. That is not a guarantee on non-Windows platforms,
so don't rely on it anymore.

Ok, I understand, but my question is, basically, "how to I solve this problem by means of the OS X API and how to do that correctly". In my understanding, correct me if I'm wrong, there is no way to use NSSavePanel.runModal and avoid main thread blocking. I simply cannot believe that the OS X API does not offer alternative, non blocking ways of displaying NSSavePanel. I cannot believe that the GUI of all Mac applications is frozen while the Save or Open dialog is shown.

Michael
Remy Lebeau (Te...


Posts: 9,447
Registered: 12/23/01
Re: Blocking TSaveDialog in OS X
Click to report abuse...   Click to reply to this thread Reply
  Posted: Jun 23, 2014 11:19 AM   in response to: Michael B in response to: Michael B
Registered wrote:

like I said, asynchronous sockets that rely on the Windows messaging
mechanism.

Asynchronous sockets are a Windows-specific implementation (introduced in
Windows 3.x to deal with its lack of support for preemptive multitasking,
which was added in Win95). They do not exist on other platforms, and non-blocking
sockets on all platforms (including Windows) do not use message queues.
So why are you using a cross-platform UI framework to handle a Windows-specific
feature?

For the sake of simplicity, imagine an application that shows the current
time on a clock. Even if you move your internal timer into a worker thread,
you can't have your GUI show the current time if the user clicked "Save
As..." and the dialog is active

Welcome to the wide world of non-Windows UI development. Other platforms
do not work the same way that Windows works. In fact, OSX has different
kinds of modal dialogs - application-modal and window-modal. A save dialog
is typically implemented as a window-modal dialog, whereas an open dialog
is typically implemented as an application-modal dialog.

https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Sheets/Sheets.html

To do what you are looking for, there has to be a message pump while the
modal dialog is shown, as demonstrated in the answer to this question:

Cocoa: how to run a modal window while performing a background task?
http://stackoverflow.com/questions/7246153/

NSApplication runModalSession
https://developer.apple.com/library/mac/documentation/Cocoa/Reference/ApplicationKit/Classes/NSApplication_Class/Reference/Reference.html#//apple_ref/occ/instm/NSApplication/runModalSession:

I don't know what version of Delphi you are using, but FireMonkey in XE6
does call NSApp.runModalSession() in a loop while a modal dialog is active,
so it has the oppurtunity to do other things while a modal dialog is active,
but I do not see it calling NSRunLoop.currentRunLoop() (or anything else)
to process pending messages inside the modal loop.

--
Remy Lebeau (TeamB)
Michael B

Posts: 5
Registered: 11/23/01
Re: Blocking TSaveDialog in OS X
Click to report abuse...   Click to reply to this thread Reply
  Posted: Jun 24, 2014 4:36 AM   in response to: Remy Lebeau (Te... in response to: Remy Lebeau (Te...
Remy Lebeau (TeamB) wrote:
Registered wrote:

like I said, asynchronous sockets that rely on the Windows messaging
mechanism.

Asynchronous sockets are a Windows-specific implementation (introduced in
Windows 3.x to deal with its lack of support for preemptive multitasking,
which was added in Win95). They do not exist on other platforms, and non-blocking
sockets on all platforms (including Windows) do not use message queues.
So why are you using a cross-platform UI framework to handle a Windows-specific
feature?

I'm actually using OverByte ICS, which works fine on OS X.


For the sake of simplicity, imagine an application that shows the current
time on a clock. Even if you move your internal timer into a worker thread,
you can't have your GUI show the current time if the user clicked "Save
As..." and the dialog is active

Welcome to the wide world of non-Windows UI development. Other platforms
do not work the same way that Windows works. In fact, OSX has different
kinds of modal dialogs - application-modal and window-modal. A save dialog
is typically implemented as a window-modal dialog, whereas an open dialog
is typically implemented as an application-modal dialog.

https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Sheets/Sheets.html

To do what you are looking for, there has to be a message pump while the
modal dialog is shown, as demonstrated in the answer to this question:

Cocoa: how to run a modal window while performing a background task?
http://stackoverflow.com/questions/7246153/

NSApplication runModalSession
https://developer.apple.com/library/mac/documentation/Cocoa/Reference/ApplicationKit/Classes/NSApplication_Class/Reference/Reference.html#//apple_ref/occ/instm/NSApplication/runModalSession:

I don't know what version of Delphi you are using,

XE5

but FireMonkey in XE6
does call NSApp.runModalSession() in a loop while a modal dialog is active,
so it has the oppurtunity to do other things while a modal dialog is active,
but I do not see it calling NSRunLoop.currentRunLoop() (or anything else)
to process pending messages inside the modal loop.

Ok, thanks for the info. Meanwhile, I've been trying to achieve this by using NSSavePanel:beginWithCompletionHandler, which displays the panel as a modeless window. When the panel is closed, the completion handler is invoked. That's the most straightforward way of preventing blocking of the main thread. Unfortunately, calling Objective C code blocks from Delphi (and that's what you have to do with beginWithCompletionHandler) is very problematic. I've found the only mention of this technique at http://stackoverflow.com/questions/23130639/calling-objective-c-code-block-from-delphi , where the author uses imp_implementationWithBlock() to create a block, but I haven't succeeded in replicating his success.

So, anyone has played with block-based completion handlers in Delphi, I'd love to see some working code.

Michael
Michael B

Posts: 5
Registered: 11/23/01
Re: Blocking TSaveDialog in OS X -- Working Code
Click to report abuse...   Click to reply to this thread Reply
  Posted: Jun 25, 2014 12:08 PM   in response to: Remy Lebeau (Te... in response to: Remy Lebeau (Te...
Remy Lebeau (TeamB) wrote:
To do what you are looking for, there has to be a message pump while the
modal dialog is shown, as demonstrated in the answer to this question:

Cocoa: how to run a modal window while performing a background task?
http://stackoverflow.com/questions/7246153/

NSApplication runModalSession
https://developer.apple.com/library/mac/documentation/Cocoa/Reference/ApplicationKit/Classes/NSApplication_Class/Reference/Reference.html#//apple_ref/occ/instm/NSApplication/runModalSession:

Ok, I've figured out how to do this. I didn't use a code-block-based-Objective-C-type completion handler, because I couldn't make a working one (if anyone knows how -- please beep), but, as you can see below, I've found a workaround. Another dirty hack that I had to use is suppressing the file overwrite prompt, but this can be handled on the application side. Other than that, below is a working module with a completely asynchronous save dialog that doesn't make your main thread freeze. If anyone finds bugs or suggests some improvements, please post them in this thread for the benefit of everybody.

Michael

unit Mac.AsyncSavePanel;

interface

uses Macapi.ObjectiveC, Macapi.CocoaTypes, System.SysUtils, Macapi.Foundation,
Macapi.CoreFoundation, FMX.Forms, Macapi.AppKit, FMX.Platform.Mac,
System.Classes, System.UITypes, System.RegularExpressions,
System.StrUtils;

type
{$M+}

NSSavePanelEx = interface (NSSavePanel)
[MethodName('beginSheetModalForWindow:completionHandler:')]
procedure beginSheetModalForWindow(window: NSWindow; Handler: Pointer); cdecl;
end;

TNSSavePanelEx = class(TOCGenericImport<NSSavePanelClass, NSSavePanelEx>)
end;

NSOpenSavePanelDelegateEx = interface(IObjectiveC)
['{B14C5C37-F7A0-4FAF-8DDE-BCE365B6D8C5}']
function panelUuserEnteredFilenameConfirmed(sender: pointer; filename: NSString; okFlag: Boolean): NSString; cdecl;
end;

TOpenSavePanelDelegateEx = class(TOCLocal, NSOpenSavePanelDelegateEx)
public
OKEventReceived: boolean;
constructor Create;
[MethodName('panel:userEnteredFilename:confirmed:')]
function panelUuserEnteredFilenameConfirmed(sender: pointer; filename: NSString; okFlag: Boolean): NSString; cdecl;
end;

TClosedDialogEvent = procedure(Sender: TObject; ClickedOK: boolean; aFileName: string;
WillOverwrite: boolean) of object;

TSaveDialogEx = class (TObject)
private
FSaveFile: NSSavePanelEx;
FDelegate: TOpenSavePanelDelegateEx;
FFilter: string;
FFilterIndex: Integer;
FInitialDir: string;
FDefaultExt: string;
FFileName: string;
FOnDialogClosed: TClosedDialogEvent;
procedure SetPanelProperties(SaveFile: NSSavePanelEx);
public
constructor Create(AOwner: TComponent);
procedure ExecuteAsync();
published
property Filter: string read FFilter write FFilter;
property FilterIndex: Integer read FFilterIndex write FFilterIndex default 1;
property InitialDir: string read FInitialDir write FInitialDir;
property DefaultExt: string read FDefaultExt write FDefaultExt;
property FileName: string read FFileName write FFileName;
property OnDialogClosed: TClosedDialogEvent read FOnDialogClosed write FOnDialogClosed;
end;

implementation

const
RANDOM_STR = 'Hnasdf4713mDhSz';

{ TOpenSavePanelDelegateEx }

function TOpenSavePanelDelegateEx.panelUuserEnteredFilenameConfirmed(
sender: pointer; filename: NSString; okFlag: Boolean): NSString;
var
s, dir: string;
SP: NSSavePanel;
begin
s:= UTF8ToString(filename.UTF8String);
if (s <> '') and okFlag then
begin
OKEventReceived:= true;
SP:= TNSSavePanel.Wrap(sender);
dir:= IncludeTRailingPathDelimiter(UTF8ToString(SP.directory.UTF8String));
{
Now, a dirty hack. We need to suppress the "Are you sure you want to replace this file?"
dialog. Why? Because if the user clicks "Yes", but then cancels the save dialog,
the last event would with okFlag = true, so our code would think that the user had confirmed
file saving. To eliminate this problem, we replace the actual file name with a non-existing one,
which suppresses this file overwrite dialog. Later, we'll revert to the original file name.
}
if FileExists( dir + s)
then Result:= NSSTR(s + RANDOM_STR)
else result:= filename;
end
else OKEventReceived:= false;
end;

constructor TOpenSavePanelDelegateEx.Create;
begin
inherited;
end;


{ TSaveDialogEx }

constructor TSaveDialogEx.Create (AOwner: TComponent);
begin
FSaveFile := TNSSavePanelEx.Wrap(TNSSavePanel.OCClass.savePanel);
FDelegate := TOpenSavePanelDelegateEx.Create;
FSaveFile.setDelegate(ILocalObject(FDelegate).GetObjectID);
end;


procedure TSaveDialogEx.ExecuteAsync;
var
NSWin: NSWindow;
begin
FDelegate.OKEventReceived := false;
NSWin := WindowHandleToPlatform(Screen.ActiveForm.Handle).Wnd;
SetPanelProperties(FSaveFile);
//Delphi cannot create Objective C code blocks, so we cannot pass a working
//completion handler here. We're passing a null handler, but we'll still detect
//form closing.
FSaveFile.beginSheetModalForWindow(NSWin, nil);
TThread.CreateAnonymousThread(
procedure
begin
repeat
Sleep(100);
until NSWin.isKeyWindow ;
//Ok, the dialog is closed, let's proceed
TThread.Synchronize(TThread.CurrentThread,
procedure
var
OverwritingExisting: boolean;
begin
FFileName := UTF8ToString(FSaveFile.URL.relativePath.UTF8String);
//Reverting the original file name as explained above, in cases
//where the user is overwriting an exiting file.
if FFileName.Contains(RANDOM_STR) then
begin
FFileName:= ReplaceStr(FFileName, RANDOM_STR, '');
OverwritingExisting:= true;
end
else OverwritingExisting:= false;

if FDefaultExt <> '' then
begin
if ExtractFileExt(FFileName) = '' then
begin
if FDefaultExt.StartsWith('.')
then FFileName:= ChangeFileExt(FFileName, FDefaultExt)
else FFileName:= ChangeFileExt(FFileName, '.' + FDefaultExt)
end;
end;
if Assigned(FOnDialogClosed)
then FOnDialogClosed(Self, FDelegate.OKEventReceived, FFileName, OverwritingExisting);
FFileName:= '';
FInitialDir:= '';
end)
end).Start;
end;

function AllocFilterStr(const s: string; var Filter: NSArray): Boolean;
var
input, pattern: string;
FileTypes: array of string;
outcome, aux: TArray<string>;
i, j: Integer;
FileTypesNS: array of Pointer;
NStr: NSString;
LocObj: ILocalObject;
begin
Result := false;
// First, split the string by using '|' as a separator
input := s;
pattern := '\|';

outcome := TRegEx.Split(input, pattern);

pattern := '\*\.';
SetLength(FileTypes, 0);

for i := 0 to length(outcome) - 1 do
begin
if Odd(i) then
if outcome[i] <> '*.*' then
if AnsiLeftStr(outcome[i], 2) = '*.' then
begin
// Split the string by using '*.' as a separator
aux := TRegEx.Split(outcome[i], pattern);
for j := 0 to length(aux) - 1 do
begin
aux[j] := Trim(aux[j]);
if aux[j] <> '' then
begin
// Remove the ';' if necessary
if AnsiEndsStr(';', aux[j]) then
aux[j] := AnsiLeftStr(aux[j], length(aux[j]) - 1);
SetLength(FileTypes, length(FileTypes) + 1);
FileTypes[length(FileTypes) - 1] := aux[j];
end;
end;
end;
end;

// create the NSArray from the FileTypes array
SetLength(FileTypesNS, length(FileTypes));
for i := 0 to length(FileTypes) - 1 do
begin
NStr := NSSTR(FileTypes[i]);
if Supports(NStr, ILocalObject, LocObj) then
FileTypesNS[i] := LocObj.GetObjectID;
end;
if length(FileTypes) > 0 then
begin
Filter := TNSArray.Wrap(TNSArray.OCClass.arrayWithObjects(@FileTypesNS[0],
length(FileTypes)));
Result := true;
end;
end;

procedure TSaveDialogEx.SetPanelProperties(SaveFile: NSSavePanelEx);
var
Filter: NSArray;
begin
SaveFile.setDirectory(NSSTR(FInitialDir));
SaveFile.setNameFieldStringValue(NSSTR(FFileName));

if FFilter <> '' then
begin
if AllocFilterStr(FFilter, Filter) then
SaveFile.setAllowedFileTypes(Filter);
end;
end;

end.

Michael B

Posts: 5
Registered: 11/23/01
Re: Blocking TSaveDialog in OS X -- Working Code
Click to report abuse...   Click to reply to this thread Reply
  Posted: Jun 26, 2014 8:17 AM   in response to: Michael B in response to: Michael B
A follow-up on the code above: You need to add a check for nil:

TThread.Synchronize(TThread.CurrentThread,
procedure
var
OverwritingExisting: boolean;
begin
if FSaveFile.URL <> nil
then FFileName := UTF8ToString(FSaveFile.URL.relativePath.UTF8String) else
begin
FFileName := '';
FDelegate.OKEventReceived:= false;
end;
.......

Michael
Legend
Helpful Answer (5 pts)
Correct Answer (10 pts)

Server Response from: ETNAJIVE02