Following is an updated example script with all the changes so far - I rearranged the script to group the delegate handlers, and extracted the checks and validations to separate handlers. I also added a requirement that the A1 and A2 textfields both must have valid values in order for the main dialog OK button to be enabled. I mostly did this so that while the OK button is disabled, using return will just end editing for any of the textFields without closing the main dialog. In the event return is used for the last required textField and its contents are valid, the main dialog will also close, since the OK button will have been enabled.
There is additional feedback (text color) when the formatted textField values are valid, and I tried to provide for most common user errors, but there are probably still some edge cases if someone tries hard enough.
use AppleScript version "2.4"
use scripting additions
use framework "Foundation"
use script "Dialog Toolkit Plus" version "1.1"
use script "Dialog Toolkit Plus Extended" version "1.1" -- datePicker
property popupItems : {"This Thing", "That Thing", "The Other Thing"} -- the operations to perform
global controlGroups -- this will be a dictionary of the alternate groups and their controls
global textColors -- this will be a list of colors for the formatted textFields
global okButton -- a reference for enabling the main `OK` button
global switching -- a flag to indicate switching to another control group while still editing
on run -- build sample controls
set controlGroups to current application's NSMutableDictionary's dictionary -- for using a keyPath
set {viewWidth, theTop} to {400, 28}
# controls are stacked from the bottom up for easier vertical positioning
set {theButtons, minWidth} to create buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel" with equal widths
set {okButton, switching} to {last item of theButtons, false}
set textColors to {current application's NSColor's textColor, current application's NSColor's systemGreenColor}
if minWidth > viewWidth then set viewWidth to minWidth
set {maxLabelWidth, controlLeft} to {100, 110}
########## begin control group #1 - these are layered in the same space, with only the active alternate shown
set newTop to theTop -- alternate A (This Thing)
set {A2_textField, A2_labelField, newTop, fieldLeft} to create side labeled field ("") left inset 0 bottom (newTop) total width viewWidth label text "This #2:" field left controlLeft
A2_textField's setDelegate:me
A2_textField's setTag:0 -- tag is used as a boolean to indicate a valid value
A2_textField's setPlaceholderString:"Required"
A2_textField's setToolTip:"This text field only accepts the characters 0-9, comma, plus, minus, space, and `E`"
set {A1_textField, A1_labelField, newTop, fieldLeft} to create side labeled field ("") left inset 0 bottom (newTop + 6) total width viewWidth label text "This #1:" field left controlLeft
A1_textField's setDelegate:me
A1_textField's setTag:0 -- tag is used as a boolean to indicate a valid value
A1_textField's setPlaceholderString:"Required"
A1_textField's setToolTip:"This text field only accepts the characters A,B, and C (case sensitive)"
repeat with anItem in (addAlternate(1, first item of popupItems, {A1_textField, A1_labelField, A2_textField, A2_labelField}, false))
(anItem's setHidden:true)
end repeat
----
set newTop to theTop -- alternate B (That Thing)
set {B3_checkbox, newTop, newWidth} to create checkbox "That Checkbox" bottom (newTop - 24) max width (viewWidth / 2) left inset controlLeft without initial state
set {B2_textField, B2_labelField, newTop, fieldLeft} to create side labeled field ("") left inset 0 bottom (newTop + 6) total width viewWidth label text "That #2:" field left controlLeft
set {B1_textField, B1_labelField, newTop, fieldLeft} to create side labeled field ("") left inset 0 bottom (newTop + 6) total width viewWidth label text "That #1:" field left controlLeft
repeat with anItem in (addAlternate(1, second item of popupItems, {B1_textField, B1_labelField, B2_textField, B2_labelField, B3_checkbox}, true))
(anItem's setHidden:true)
end repeat
########## end control group #1
set theTop to newTop -- continue from previous top
set {divider, theTop} to create rule (theTop + 12) rule width viewWidth -- NSBox
set {invoiceTextField, invoiceLabelField, theTop, fieldLeft} to create side labeled field ("") bottom (theTop + 12) total width (viewWidth / 2) label text "Invoice number:" field left controlLeft
set {contractorTextField, contractorLabelField, theTop, fieldLeft} to create side labeled field ("") bottom (theTop + 12) total width viewWidth label text "Contractor:" field left controlLeft
set {clientTextField, clientLabelField, theTop, fieldLeft} to create side labeled field ("") bottom (theTop + 12) total width viewWidth label text "Client:" field left controlLeft
set {dateLabelField, theTop} to create label " Date:" left inset 60 bottom (theTop + 12) max width maxLabelWidth control size regular size
set {datePicker, theTop} to create textual datepicker textual stepper picker elements YearMonthDay initial date (current date) min date seconds 0 max date seconds 0 left inset controlLeft bottom (theTop - 20) extra width 0 extra height 0
set {operationPopup, theTop} to create popup popupItems left inset controlLeft bottom (theTop + 8) popup width 180 initial choice (last item of popupItems)
operationPopup's setToolTip:"The operation to perform"
operationPopup's setTarget:me
operationPopup's setAction:"popupAction:" -- choose from group #1
set allControls to {operationPopup, dateLabelField, datePicker, clientLabelField, clientTextField, contractorLabelField, contractorTextField, invoiceLabelField, invoiceTextField, divider, A1_labelField, A1_textField, A2_labelField, A2_textField, B1_labelField, B1_textField, B2_labelField, B2_textField, B3_checkbox} -- control references are listed from top to bottom for easier indexing
set {buttonName, controlValues} to display extended window "Descriptive Title" acc view width viewWidth acc view height theTop acc view controls allControls buttons theButtons without align cancel button
doStuff for (formatResult from controlValues)
end run
# Extract the desired controls from the returned dialog values and group into a record.
to formatResult from dialogResult
tell (date (item 3 of dialogResult) as «class isot» as string) to set ISODate to text 9 thru 10 & "/" & text 6 thru 7 & "/" & text 1 thru 4 -- format date using the built-in ISO date class (no time zone info)
tell dialogResult -- use record for consistent key:value pairs
set common to {operation:item 1, theDate:ISODate, client:item 5, contractor:item 7, invoice:item 9}
if first item is first item of popupItems then -- common + alternate A
set results to common & {A1_textField:item 12, A2_textField:item 14}
else if first item is second item of popupItems then -- common + alternate B
set results to common & {B1_textField:item 16, B2_textField:item 18, B3_checkbox:item 19}
else if first item is third item of popupItems then
set results to common
else -- oops
set results to missing value
end if
end tell
return results
end formatResult
# Add a set of alternate controls to the controlGroups dictionary - controls are returned for further customization.
to addAlternate((group as text), (keyName as text), (controls as list), (okEnabled as boolean))
if (controlGroups's valueForKey:group) is missing value then (controlGroups's setValue:(current application's NSMutableDictionary's dictionary()) forKey:group) -- add new group as needed
controlGroups's setValue:(current application's NSMutableDictionary's dictionaryWithDictionary:{controls:controls, okEnabled:okEnabled}) forKeyPath:(group & "." & keyName) -- add keyName alternate controls to group
return controls
end addAlternate
# Action to change the visibility of group #1 controls based on the popup value.
on popupAction:sender
set switching to true
repeat with aKey in popupItems -- hide everything
set controlList to (controlGroups's valueForKeyPath:("1." & aKey & ".controls"))
if controlList is not missing value then repeat with aControl in controlList
((contents of aControl)'s setHidden:true)
end repeat
end repeat
set controlList to (controlGroups's valueForKeyPath:("1." & ((sender's title) as text) & ".controls"))
if controlList is not missing value then repeat with aControl in controlList -- show the selected control group
((contents of aControl)'s setHidden:false)
end repeat
set enabled to (controlGroups's valueForKeyPath:("1." & ((sender's title) as text) & ".okEnabled"))
if enabled is missing value then set enabled to true -- default
enableOK(enabled as boolean) -- enable/disable button according to control group setting
set switching to false
end popupAction:
# Do stuff for the dialog result values.
to doStuff for dialogValues
tell (operation of dialogValues) to if it is first item of popupItems then
-- perform `This` with dialogValues for this operation
else if it is second item of popupItems then
-- perform `That` with dialogValues for that operation
else if it is third item of popupItems then
-- perform `The Other` with dialogValues for the other operation
end if
return dialogValues
end doStuff
###################################
# Delegate Handlers and Utilities
###################################
# Check changes to a formatted textField.
on controlTextDidChange:notificationObject
set object to notificationObject's object -- the textField that posted the notification
set {A1, A2} to {item 1, item 3} of (controlGroups's valueForKeyPath:"1.This Thing.controls") -- the controls to compare
if (object's isEqual:A1) as boolean then
checkAcceptable(object, characters of "ABC")
else if (object's isEqual:A2) as boolean then
checkAcceptable(object, characters of "1234567890E,+- ")
end if
enableOK(false)
end controlTextDidChange:
to checkAcceptable(textFieldRef, acceptable) -- check for acceptable characters - others are removed
set indicated to false
textFieldRef's setTextColor:(first item of textColors) -- reset to normal text color
set theString to textFieldRef's stringValue as text
repeat with aCharacter in (characters of theString) -- just check the whole string in case something was pasted
considering case
if aCharacter is not in acceptable then -- indicate and remove
if not indicated then -- avoid multiple beeps
current application's NSBeep()
set indicated to true
end if
set theString to (replaceText of theString from aCharacter to "")
(textFieldRef's setStringValue:theString)
end if
end considering
end repeat
end checkAcceptable
# Validate textField contents after editing.
on controlTextDidEndEditing:notificationObject
set object to notificationObject's object -- the textField that posted the notification
set theString to object's stringValue as text
set {A1, A2} to {item 1, item 3} of (controlGroups's valueForKeyPath:"1.This Thing.controls") -- the controls to compare
if (object's isEqual:A1) as boolean then
updateValidity(object, validate_A1(theString), "Unacceptable A1_textField - please enter a valid string.")
else if (object's isEqual:A2) as boolean then
updateValidity(object, validate_A2(theString), "Unacceptable A2_textField - please enter a valid number.")
end if
set valid to (A1's stringValue as text is not "") and (A1's tag as integer is not 0) and ¬
(A2's stringValue as text is not "") and (A2's tag as integer is not 0)
(controlGroups's setValue:valid forKeyPath:"1.This Thing.okEnabled")
(my enableOK(valid)) -- only enable if everything has a valid value
end controlTextDidEndEditing:
to updateValidity(textFieldRef, (valid as boolean), (errorMessage as text))
textFieldRef's setTag:(valid as integer)
textFieldRef's setTextColor:(item ((valid as integer) + 1) of textColors) -- visual feedback
if not valid then -- indicate that editing is needed
if not switching then if errorMessage is not "" then (showPopover for errorMessage at textFieldRef)
if switching then textFieldRef's setStringValue:"" -- clear
end if
end updateValidity
to validate_A1(theString) -- test if A1_textField contains a valid string
try
-- whatever
return true
end try
return false
end validate_A1
to validate_A2(theString) -- test if A2_textField contains a valid number
try
theString as number
return true
end try
return false
end validate_A2
to enableOK(flag as boolean) -- enable the OK button according to the flag value
okButton's setEnabled:flag
okButton's setKeyEquivalent:(item ((flag as integer) + 1) of {"", return})
end enableOK
to replaceText of (theText as text) from (oldText as text) to (newText as text)
set {prevTID, AppleScript's text item delimiters} to {AppleScript's text item delimiters, oldText}
set {itemList, AppleScript's text item delimiters} to {text items of theText, newText}
set {theText, AppleScript's text item delimiters} to {itemList as text, prevTID}
return theText
end replaceText
to showPopover for (message as text) at viewRef given edge:edge as integer : 1 -- given arguments are optional
if (message is "") or (viewRef is missing value) then return
set viewController to current application's NSViewController's alloc's init
tell (current application's NSTextField's labelWithString:message)
its setFrameOrigin:{10, 10} -- view inset
its setAlignment:(current application's NSTextAlignmentCenter)
its setFont:(current application's NSFont's fontWithName:"Helvetica" |size|:14)
its setTextColor:(current application's NSColor's systemRedColor)
its sizeToFit() -- adjust textField to string after text settings
tell (its frame) to set {width, height} to {current application's NSWidth(it), current application's NSHeight(it)}
viewController's setView:(current application's NSView's alloc's initWithFrame:{{0, 0}, {width + 20, height + 18}})
viewController's view's addSubview:it
end tell
tell current application's NSPopover's alloc's init()
its setContentViewController:viewController
its setBehavior:(current application's NSPopoverBehaviorTransient)
its showRelativeToRect:{{0, 0}, {0, 0}} ofView:viewRef preferredEdge:edge -- default NSMinYEdge
end tell
end showPopover
There shouldn’t be any lag when editing, unless your production script is doing something. You can also just avoid editing an invalid value after the error dialog by clearing the textField (this is also done if switching controls while editing).
Edited to update script:
- Cleaned up duplicate code in the delegate and supporting handlers.
- Added handler to use popovers as a workaround for issues discovered when using
display alert or display dialog with the Dialog Toolkit in a script application.