Open/save panels in Yosemite

Standard Additions’ choose file, choose folder and choose file name commands are staples of AppleScript, and on the whole they serve us very well. But there are some areas where they come up short, and in such cases AppleScriptObjC offers a way of working around the limitations.

In the case of choose file name, the dialog includes the tags field, but offers no way for us to retrieve those values. It probably doesn’t matter in most cases, but some users are going to get annoyed if they spend time adding tags, only to have them ignored. Another minor issue is the way the default name is selected; choose file name shows with the whole name selected, rather than the bit before the extension selected.

In some cases, you may want the New Folder button not to appear. Also, choose file name does not give you the option of enforcing a particular extension.

And there’s another option I’ll come to later, where you can add extra stuff to the panel.

The choose file name command shows what is known as an NSSavePanel, and this code shows one directly. It has a lot more parameters, and you can set only those that matter to you.

Before you run the following code, a warning: ASObjC code that involves drawing stuff on screen, like this, has to be run in the foreground on what is known as the main thread. If it is run on a background thread, the likely result is that the app will crash. Scripts are nearly always run on the main thread – the one exception being in script editors, where they are usually run on background threads. So when you are trying out this code, you need to make sure you run it in the foreground.

In Script Editor, you run a script in the foreground by holding the control key when running it, or pressing control-command-R. You can see the Run command change names in the Script menu when you hold down the control key. In ASObjC Explorer you check the ‘Run in foreground’ checkbox at the bottom of the script window.

Because it is easy to forget when you are testing, especially in Script Editor, I’ve added some extra code that checks if it is running in the foreground, and if not shows a dialog and stops, rather than letting the app crash. This simply asks the NSThread class if the current code is on the main thread.

I haven’t written it as a handler because of the potentially varied number of arguments, but you could do so and put it in a script library.

use AppleScript version "2.4"
use scripting additions
use framework "Foundation"
use framework "AppKit" -- needed for NSSavePanel

-- check we are running in foreground
if not (current application's NSThread's isMainThread()) as boolean then
	display alert "This script must be run from the main thread." buttons {"Cancel"} as critical
	error number -128
end if

-- make panel
set savePanel to current application's NSSavePanel's savePanel()
tell savePanel
	-- set main values
	its setMessage:"Your message here." -- AS's prompt
	its setNameFieldStringValue:"Untitled.txt" -- AS's default name
	-- other values you *can* set
	its setDirectoryURL:(current application's class "NSURL"'s fileURLWithPath:(POSIX path of (path to desktop))) -- AS's default directory
	its setTitle:"Save File" -- Panel's title; default is "Save"
	its setPrompt:"Save" -- Override name on button; default is "Save"
	its setCanCreateDirectories:true -- whether New Folder button appears; default is true
	its setAllowedFileTypes:{"txt"} -- extensions or UTIs; provide missing value if any are allowed
	its setAllowsOtherFileTypes:false -- whether user can enter other file type; default is false
	its setShowsHiddenFiles:false -- whether hidden files appear; default is false
	its setTreatsFilePackagesAsDirectories:true -- whether packages are treated as files or folders; default is files
	its setShowsTagField:true -- whether to show the tags field; default is true
	its setTagNames:{"Important"} -- initial list of proposed tags; default is {}
end tell
-- show panel
set returnCode to savePanel's runModal()
-- eror if Cancel pressed
if returnCode is (current application's NSFileHandlingPanelCancelButton) then
	error number -128
end if
-- get entered path and tags
set thePosixPath to (savePanel's |URL|()'s |path|()) as text
set theTags to savePanel's tagNames() as list
return {thePosixPath, theTags}

There are similar alternatives for choose file and choose folder, and I’ll cover them shortly.

The equivalent of choose file/folder is similar, and offers two sometimes-requested facilities: the ability to let the user choose files and folders in the one dialog, and the ability to decide whether alias files are resolved or passed directly.

Again, the warning: ASObjC code that involves drawing stuff on screen, like this, has to be run in the foreground on what is known as the main thread. If it is run on a background thread, the likely result is that the app will crash. This code checks it’s on the main thread first, to avoid a crash.

In Script Editor, you run a script in the foreground by holding the control key when running it, or pressing control-command-R. You can see the Run command change names in the Script menu when you hold down the control key. In ASObjC Explorer you check the ‘Run in foreground’ checkbox at the bottom of the script window.

use AppleScript version "2.4"
use scripting additions
use framework "Foundation"
use framework "AppKit" -- needed for NSSavePanel

-- check we are running in foreground
if not (current application's NSThread's isMainThread()) as boolean then
   display alert "This script must be run from the main thread." buttons {"Cancel"} as critical
   error number -128
end if

-- make panel
set openPanel to current application's NSOpenPanel's openPanel()
tell openPanel
	-- set main values
	its setMessage:"Your message here" -- AS's prompt
	-- other values you *can* set
	its setDirectoryURL:(current application's class "NSURL"'s fileURLWithPath:(POSIX path of (path to desktop))) -- AS's default directory
	its setAllowsMultipleSelection:true -- AS's multiple selections allowed
	its setAllowedFileTypes:{"txt"} -- AS's of type. Provide missing value for all types
	its setShowsHiddenFiles:false -- AS's invisibles; default is false
	its setTreatsFilePackagesAsDirectories:false -- AS's showing package contents; default is false
	its setTitle:"Choose a File" -- Panel's title; default is "Open"
	its setPrompt:"Open" -- Override name on button; default is "Open"
	its setCanChooseFiles:true -- whether it's like choose file; default is true
	its setCanChooseDirectories:true -- whether it's like choose folder; default is false
	its setResolvesAliases:true -- whether aliases are automatically resolved; default is true
end tell
-- show panel
set returnCode to openPanel's runModal()
if returnCode is (current application's NSFileHandlingPanelCancelButton) then
	error number -128
end if
-- get chosen paths and tags
set thePosixPaths to (openPanel's |URL|()'s valueForKey:"path") as list
set theTags to openPanel's tagNames()
if theTags = missing value then
	set theTags to {}
else
	set theTags to theTags as list
end if
return {thePosixPaths, theTags}

The other reason you might want to use an open/save panel is the ability to add extra stuff to it. This is called an accessory view – an area at the bottom where you can add extra controls, as you see in lots of applications’ open and save dialogs. This is an example of how to create an open panel with an accessory view containing a single checkbox. You can of course make more complex accessory views, but this shows the principle.

use AppleScript version "2.4"
use scripting additions
use framework "Foundation"
use framework "AppKit" -- needed for NSSavePanel

-- check we are running in foreground
if not (current application's NSThread's isMainThread()) as boolean then
display alert "This script must be run from the main thread." buttons {"Cancel"} as critical
error number -128
end if

-- create a view
set theView to current application's NSView's alloc()'s initWithFrame:(current application's NSMakeRect(0, 0, 400, 40))
-- create a button
set theButton to current application's NSButton's alloc()'s initWithFrame:(current application's NSMakeRect(100, 10, 150, 20))
-- make button a checkbox and give it a title
theButton's setButtonType:(current application's NSSwitchButton)
theButton's setTitle:"I am a checkbox"
-- add the checkbox to the view
theView's addSubview:theButton

-- make open panel
set openPanel to current application's NSOpenPanel's openPanel()
tell openPanel
	-- set main values
	its setMessage:"Your message here" -- AS's prompt
	-- other values you *can* set
	its setAllowsMultipleSelection:true -- AS's multiple selections allowed
	its setAllowedFileTypes:{"txt"} -- AS's of type. Provide missing value for all types
	
	-- add the view to the panel
	its setAccessoryView:theView
end tell
-- show panel
set returnCode to openPanel's runModal()
if returnCode is (current application's NSFileHandlingPanelCancelButton) then
	error number -128
end if
-- get chosen paths and tags
set thePosixPaths to (openPanel's |URL|()'s valueForKey:"path") as list
set theTags to openPanel's tagNames()
if theTags = missing value then
	set theTags to {}
else
	set theTags to theTags as list
end if
set theState to theButton's state() as boolean
return {thePosixPaths, theTags, theState}

You can get more complex – for example, a control in the accessory view can call a handler in your script while the dialog is still showing. Add these two lines after setting theButton’s title:

theButton's setTarget:me
theButton's setAction:"checkboxChecked:"

That tells the checkbox that when it’s clicked it should call a handler named “on checkboxChecked:” that belongs to me (the script). Now add a handler of that name at the end:

on checkboxChecked:sender
	set theState to sender's state() as boolean
	if theState then
		display dialog "The checkbox is now checked"
	else
		display dialog "The checkbox is now unchecked"
	end if
end checkboxChecked:

Clicking the checkbox should now display a dialog.

Just remember to run it in the foreground.

Hi Shane.

Thanks for posting these!

There’s a namespace clash over NSURL on my machine, so that the scripts in your first two posts error with “nsurl doesn’t understand the “fileURLWithPath_” message.” The offending line is this:

its setDirectoryURL:(current application's nsurl's fileURLWithPath:(POSIX path of (path to desktop))) -- AS's default directory

The scripts work when the term’s barred:

its setDirectoryURL:(current application's |NSURL|'s fileURLWithPath:(POSIX path of (path to desktop))) -- AS's default directory

Thanks, Nigel. Do you happen to know which scripting addition uses NSURL? I thought it was Satimage, but I don’t see it here, and I’m wondering if it’s been changed, or I’m just mis-remembering.

Nice posts Shane, also great to explain the accessory view. Used it many times for a user to save a file and immediately giving the option to choose the file format or character encoding.

:cool:

Ah. Found the culprit: Satimage’s XMLLib OSAX, 3.6.1. Not one I’ve actually used, so not updated for a couple of years. Ironically, an nsurl is a property of a namespace!

nsurl is always available in XMLLib 3.7.0

Yvan KOENIG (VALLAURIS, France) lundi 17 novembre 2014 12:24:43

Maybe someone who is on the Smile mailing list can suggest they change it – it’s not a good thing to use Cocoa class names as terminology.

First of all you’re absolutely right!

But for now you can change it into:

...class "NSURL"'s....

From the start I have somehow learned myself creating properties for the Cocoa classes to avoid this global scope issue. So I had at the top of my scripts something like:

property OBJCString : class "NSString"

Two flies in one hit in this case as a perfect example why (only a) global namespace is bad. AppleScript support namespaces but it’s extensions (OSAX) will be added to the global namespace. If you would ask me what to change about AppleScript it would be support for puttig scripting additions in context individually. But AppleScript itself like scripting applications and using script libraries is doing a fine job when it comes to namespaces.

But Objective-C on the other hand has no support for namespace at all. And this example shows perfectly why adding a couple of characters as a prefix to class names is no solution for the lack of namespaces. Therefore swift could be the answer, however for backward compatibility the entire foundation framework is added to the global scope which would cause the same error. If there will be ever an AppleScriptSwift bridge I think it would be perfect that every function or class should be addressed using explicitly it’s implicit namespace, which the name of the framework.

Yes, including “class” and using a string is a good idea.

I’m torn about the idea of defining classes in properties. I did it a lot when I first started, but I found I was referring to a lot of classes maybe only once or twice, and ended up more work and house-keeping than it was worth. Admittedly this was in longer scripts, in Xcode-based projects.

Now I have another motive not to: ASObjC Explorer’s code-completion can more easily analyze code without it. Thus if it sees "current application’s XYZ’s ", it knows XYZ is a class and it can offer XYZ’s class methods as completions, whereas without the “current application’s”, it presumes XYZ is an instance. And really, I think I’d go mad writing this stuff without code-completion.