If you simply recompile a Lazarus or converted Delphi app with Lazarus on Mac, you'll end up with a vaguely Win95 look and feel that would likely fall short of the user interface guidelines of Apple's new Mac App Store. However, with a few conditionally compiled code additions, you can get your app to act a lot more like a standard Mac app.
Here are some problem areas that this article attempts to address:
Also, I've created several small convenience units and added them to the XDev Toolkit (a miscellaneous collection of stuff I've developed over the years). The CFHelpers unit has routines that make it easier to work with OS X Core Foundation (CF) strings in Pascal. The PropListUtil unit makes working with property list files easier and the PrefsUtil unit makes reading and writing application preferences easier. The XDev Toolkit is here:
http://wiki.lazarus.freepascal.org/XDev_Toolkit
Apple's Mac App Store guidelines are here (login required):
Mac App Store Review Guidelines
When you compile an app on Mac with Lazarus, you automatically get the app menu without doing anything yourself, but this default app menu lacks two menu items that typically appear in different menus on other platforms, namely the About and Preferences items. Fortunately it's easy to add these yourself conditionally.
First, you'll need to add something like this to your main form's declaration:
{$IFDEF DARWIN} AppMenu : TMenuItem; AppAboutCmd : TMenuItem; AppSep1Cmd : TMenuItem; AppPrefCmd : TMenuItem; {$ENDIF}Now add this in the main form's FormCreate handler or some other startup code:
{$IFDEF DARWIN} AppMenu := TMenuItem.Create(Self); {Application menu} AppMenu.Caption := #$EF#$A3#$BF; {Unicode Apple logo char} MainMenu.Items.Insert(0, AppMenu); AppAboutCmd := TMenuItem.Create(Self); AppAboutCmd.Caption := 'About ' + BundleName; //<== BundleName set elsewhere AppAboutCmd.OnClick := AboutCmdClick; AppMenu.Add(AppAboutCmd); {Add About as item in application menu} AppSep1Cmd := TMenuItem.Create(Self); AppSep1Cmd.Caption := '-'; AppMenu.Add(AppSep1Cmd); AppPrefCmd := TMenuItem.Create(Self); AppPrefCmd.Caption := 'Preferences...'; AppPrefCmd.Shortcut := ShortCut(VK_OEM_COMMA, [ssMeta]); AppPrefCmd.OnClick := OptionsCmdClick; //<== "Options" on other platforms AppMenu.Add(AppPrefCmd); {$ENDIF}Note that the app menu already has a Quit command, so you'll want to delete (conditionally) any Quit or Exit command from your File menu, as well as remove About and Preferences commands if they appear elsewhere in the main menu (in which case you can just reuse them here instead of creating them again).
That works fine if you're targeting only Mac, but what if you have a cross-platform app and you're perfectly happy with the "square" (old fashioned) look on Windows and Linux and want oval buttons only on Mac? Unfortunately, Lazarus form design files (.lfm), like Delphi's form design files (.dfm), don't support any kind of conditional property values. So what to do?
One solution would be to have two sets of form files, but this seems like overkill if all you want is oval buttons, although it does allow you then to modify anything else in the form (useful elsewhere as we'll see later).
Another approach is to use a utility like the XDev Toolkit's DfmToLfm converter to reduce button height automatically in form files that you're converting from Delphi.
A third possibility would be to reduce button height at runtime, using conditional code.
On a Mac dialog with a Cancel button, the Cancel button is positioned to the left of the "action" button (OK, Save, etc.). This is opposite the usual way that buttons are positioned on Windows. Again, what to do?
If you don't want two sets of form files, you could do something like this to avoid sprinkling conditional code throughout your form Pascal files:
procedure TMainForm.CheckOKCancelBtns(OKBtn : TControl; CancelBtn : TControl); {Swap OK and Cancel button positions on Mac.} {$IFDEF DARWIN} var SaveLeft : Integer; begin if OKBtn.Left < CancelBtn.Left then begin SaveLeft := OKBtn.Left; OKBtn.Left := CancelBtn.Left; CancelBtn.Left := SaveLeft; end; {$ELSE} {Do nothing} begin {$ENDIF} end; {TMainForm.CheckOKCancelBtns}Now each place where you invoke a dialog box, add a single line of code (which is basically a no-op on other platforms):
SomeDlg := TSomeDlg.Create(Application); try CheckOKCancelBtns(SomeDlg.OKButton, SomeDlg.CancelButton); if ShowDlg.ShowModal <> mrOK then Exit; ... finally SomeDlg.Free; end; end;While improper button position is one thing that will really flag your app as a port from an alien platform, there are other important button differences as well. For example, Mac buttons normally appear at the bottom right of the dialog. For more information, refer to Apple's Human Interface Guidelines (HIG):
Apple Human Interface Guidelines
When a Mac app does need a user response before it can proceed, it normally uses a modal "sheet", not a modal dialog. A modal sheet does not block access to the rest of the application the way a modal dialog does. It also has a different look, "descending" from the main window's title bar as though pulled down ("unfurl" is Apple's term), rather than popping up like a dialog. A sheet is "document modal" rather than "application modal".
Unfortunately, sheets are an alien concept to Delphi VCL and thus Lazarus LCL, so you won't be able to use them in your Lazarus apps. And you will probably never be able to use them, even in a future Cocoa-based Lazarus, unless a sea change occurs in the LCL and support is added for sheets. If you're developing in Pascal with Cocoa but not using the LCL, then of course you can (and should) use sheets, but with the LCL this feature will probably remain out of reach. Part of the reason is that when a sheet is run, the app doesn't wait around until the sheet is closed the way it does when ShowModal is called. Instead, when the sheet ends, its delegate object is called and that object handles the sheet's results. This requires a different program structure than modal dialogs (calling code handles results) or modeless dialogs (dialog handles close event).
To display a dialog modelessly while retaining its modal behavior on Windows, you can do something conditionally like this:
{$IFDEF DARWIN} if Assigned(AboutBox) then {Already displayed?} Exit; AboutBox := TAboutBox.Create(Application); AboutBox.OKButton.Visible := False; AboutBox.Show; {$ELSE} AboutBox := TAboutBox.Create(Application); try AboutBox.ShowModal; finally AboutBox.Free; end; {$ENDIF}Be sure to add an OnClose handler to your dialog like this:
procedure TAboutBox.FormClose( Sender: TObject; var Action: TCloseAction); begin {$IFDEF DARWIN} Action := caFree; AboutBox := nil; {Okay to display again} {$ENDIF} end;Note this assumes you're using the global form variable that the Delphi and Lazarus form designers declare for you automatically. On Mac, we're also using that variable to determine whether it's okay to display the dialog since we don't want multiple instances of it.
What else can you do to make your forms look and act more like they do on a Mac? Well, you can make sure that tabbing between controls works correctly.
On a Mac, quite a few controls such as buttons, combo boxes and so on do not receive the keyboard focus. They can only be operated with the mouse. This interferes with normal VCL/LCL tab stops. In fact, it's impossible to tab past one of these controls unless you set the control's TabStop property to False. Again, if you're targeting only Mac, this is easy to do. But if your app targets Windows too, that just flips the tabbing problem back to Windows.
The XDev Toolkit's DfmToLfm converter handles this by inserting TabStop = False into the .lfm files it creates from Delphi .dfm files. You could do something similar. That is, you could have two sets of .lfm files. You only edit one set and auto-create the other using some kind of converter, which inserts the TabStop property. Then on Mac you would conditionally use the second set of forms.
A property list file is just an XML file of key-value pairs. You tell OS X about your app via the app's Info.plist file. You can also use it to store version information for your app.
Actually, there's already a number of keys in the Info.plist file that correspond to items in a version resource embedded in a Windows executable. For example, the Info.plist key CFBundleShortVersionString key is similar to FileVersion and the NSHumanReadableCopyright key can be used like LegalCopyright.
When you first compile a Lazarus app, you can let the IDE create the app bundle and its Info.plist file for you, but this is only the most rudimentary of Info.plist files. To edit and add to it, you can either use a text editor or you can double-click the Info.plist file and edit it in the OS X Property List Editor app.
You can also add your own custom keys, naming the key using your app's bundle identifier to ensure they're unique and won't collide with any future Apple keys. For example, your bundle identifier should be in this form:
<key>CFBundleIdentifier</key> <string>com.mycompany.myapp</string>Now just use it to prefix your own keys, like this to add CompanyName to your Info.plist file:
<key>com.mycompany.myapp.CompanyName</key> <string>My Company, Inc.</string>To retrieve version info (or anything else) from your app's Info.plist file at runtime (for use in an About box, for example), you can employ something like this little function from the PropListUtil unit:
function GetInfoPlistString(const KeyName : string) : string; {Retrieve key's string value from app bundle's Info.plist file.} var BundleRef : CFBundleRef; KeyRef : CFStringRef; ValueRef : CFTypeRef; begin Result := ''; BundleRef := CFBundleGetMainBundle; if BundleRef = nil then {Executable not in an app bundle?} Exit; AnsiStrToCFStr(KeyName, KeyRef); try ValueRef := CFBundleGetValueForInfoDictionaryKey(BundleRef, KeyRef); if CFGetTypeID(ValueRef) <> CFStringGetTypeID then {Value not a string?} Exit; Result := CFStrToAnsiStr(ValueRef); finally FreeCFRef(KeyRef); end; end; {GetInfoPlistString}You use the function like this in your program:
BundleName := GetInfoPlistString('CFBundleName'); BundleId := GetInfoPlistString('CFBundleIdentifier'); CompanyName := GetInfoPlistString(BundleId + '.CompanyName');You can also use the PropListUtil unit's TCFPropertyList class to load an entire property list file.
For more information about Info.plist keys, refer to Apple's docs:
Information Property List Key Reference
Since preferences are stored in a property list file (key-value pairs), the preference values can be any of the property list object types (CFString, CFBoolean, CFNumber, CFDate, CFData, CFArray and CFDictionary). For example to write a string value to the preferences file, you could use the TCFPreferences class from the PrefsUtil unit:
Prefs := TCFPreferences.Create; try Prefs.SetAppString('Editor:FontName', 'Monaco'); finally Prefs.Free; end;Note the format of the key name. This is the way TextWrangler names its preference keys; it seems like a good approach.
To read a preference string value, you can use the same class:
Prefs := TCFPreferences.Create; try FontName := Prefs.GetAppString('Editor:FontName'); finally Prefs.Free; end;For more information about preferences, refer to Apple's docs:
Adding an icon to your app bundle
The next step is to associate that icon with file types that your app can open and also to associate those file types with your app. You do that by adding a section to your app bundle's Info.plist file that looks like this (for files with the .myext extension):
<key>CFBundleDocumentTypes</key> <array> <dict> <key>CFBundleTypeRole</key> <string>Editor</string> <key>CFBundleTypeExtensions</key> <array> <string>myext</string> </array> <key>CFBundleTypeIconFile</key> <string>myapp.icns</string> <key>CFBundleTypeMIMETypes</key> <string>text/myapp</string> <key>CFBundleTypeName</key> <string>My App</string> <key>CFBundleTypeOSTypes</key> <array> <string>****</string> </array> </dict> </array>But this isn't really of much use if your app can't open a file that's been double-clicked. Typically on Windows you check ParamStr(1) at startup to see if a file name was passed to the app on the command line as a result of double clicking the file. But ParamStr doesn't work that way on OS X. Instead, you need to add a drop-file handler to your main form, as follows:
{$IFDEF DARWIN} procedure DropFiles( Sender : TObject; const FileNames: array of string); {$ENDIF}Then do this at startup, for example in your main form's FormCreate handler:
{$IFDEF DARWIN} OnDropFiles := DropFiles; {$ENDIF}And implement the handler like this:
{$IFDEF DARWIN} procedure TMainForm.DropFiles( Sender : TObject; const FileNames : array of string); {When start app by double-clicking one of its files, file name passed to this event handler.} begin if High(FileNames) >= 0 then {At least one file passed?} begin {do something with FileNames[0]} end; end; {$ENDIF}A few things worth noting:
<meta name="AppleTitle" content="My App's Help">
<key>CFBundleHelpBookFolder</key> <string>Help</string> <key>CFBundleHelpBookName</key> <string>My App's Help</string>
To provide context sensitive help and also searchable topics and keywords, you use the OS X Help Indexer app:
hiutil -Caf Help.helpindex .
procedure TForm1.Button1Click(Sender: TObject); var AnchorCFStrRef : CFStringRef; Status : OSStatus; begin AnsiStrToCFStr(HelpKeyword, AnchorCFStrRef); Status := AHLookupAnchor(nil, AnchorCFStrRef); FreeCFRef(AnchorCFStrRef); end;A few things worth noting:
MainMenu.Items.Remove(HelpMenu);
com.apple.helpviewer com.apple.helpdIf order to empty the trash, you may need to shut down helpd (although it usually shuts down on its own in a few minutes). You can use the OS X Activity Monitor app to do this.
Last revised Nov 17, 2010.