-- FORMAT NUMBERS -- -- HISTORY -- -- 18 Sep 1998: written for the D7 Behaviors Palette by James Newton -- 1 Nov 1998: now treats O and numbers between the maxInteger -- and 9.5e13 correctly. Minus sign acts as a toggle. -- NOTES FOR DEVELOPERS -- -- This behavior has four distinct functions: -- * Filtering input characters -- * Stripping non-numerical characters from an input string -- * Converting a number to a formatted string -- * Allowing the user to use TAB or RETURN to move to the next -- editable item -- FILTERING INPUT -- The FilterInput handler is called (on keyDown) each time the user -- presses a key. BACKSPACE, the Arrow keys and any characters -- expressly allowed in the Behavior Parameters dialog are allowed to -- pass through and affect the screen display. TAB and RETURN are -- used to apply the chosen format to the field and jump to the next -- editable item. Any other characters simply provoke a system beep. -- (Only one decimal point character will be permitted.) -- -- The arrow keys have to be treated slightly differently from the -- character keys: the keyCode is used instead of the key, This gives -- the same results cross-platform. -- STRIPPING NON-NUMERICAL CHARACTERS -- The user's input must be evaluated. The GetValue handler first -- converts the input string to a number, using the value() function. -- It then compares this value to the string itself. Director is able -- to detect when a string is a simple number: if this is the case, -- the handler's work is done. -- -- If, on the other hand, the user included non-numeric characters, -- such as currency symbols, commas or spaces, then these must be -- stripped out. The safest (but slowest) method is to work through -- the string, character by character. A local variable called -- theValue is first set to zero, then multiplied by 10 each time a -- new digit is encountered. The new digit is then added to the -- result. This creates an integer that grows by a power of ten at -- each step. -- -- If the character representing the decimal point is encountered, -- another local variable name figuresAfterPoint begins to increment. -- At the end, its value will be either zero or one more than the -- number of figures after the point. If figuresAfterPoint is zero, -- an integer is returned. If not, the integer theValue is divided by -- a floating point number representing a power of ten. This converts -- it to a floating point number with the decimal point in the right -- place. -- FORMATTING THE NUMBER -- Now that the user's input has been converted to a number, it can -- now be formatted. It is possible that the formatting will simply -- restore the number to the string the user originally typed: the -- behavior may seem to have done a lot of calculations for nothing. -- This is the price to pay for being user-friendly. -- -- The MoneyFormat handler has to deal with three questions: -- * how to handler cents -- * how to group the number in powers of a thousand -- * how to align the number with other numerical items -- -- Rounding cents to the nearest dollar is the most complex case. -- Director always rounds 0.5 up to the next integer. This may lead -- to apparent inaccuracies. The mathematical convention is to round -- to the nearest even number. -- -- Units are incremented by one if there are more than 50 cents. To -- force 50 cents to round UP to an even number, a single cent is -- added if the units are currently an odd number. The mod operator -- is used to do this: (x mod 2) = 1 if x is odd. -- -- Note that the true value is stored as property of the behavior, -- independently of any screen display. Modifying the value of the -- cents, then not displaying any cents thus has no destructive -- effect. -- -- To group the non-decimal part in chunks of three numbers, I create -- a repeat loop which counts down in steps of three. Director does -- not have a 'step' keyword. Instead I had to create my own system: -- -- repeat while thousandsLength > 0 -- ... -- thousandsLength = thousandsLength - 3 -- end repeat -- -- Alignment in Text members is achieved by inserting a TAB character -- at the beginning of the string. This will cause the string to -- align with a decimal tab (which the designer must place by hand). -- Field members should be right aligned to achieve a similar effect. -- KNOWING WHEN TO FORMAT -- The ApplyFormat handler should be activated by any of three -- actions: -- * when a field is deselected by a click elsewhere -- * when the RETURN key is pressed -- * when the TAB key is pressed -- -- The last two actions should also tab to the next editable sprite -- with the same behavior (see below). Treating deselection-by-mouse- --click would be automatic if the RETURN and TAB actions did not have -- to be dealt with. As it is it has to be done with Lingo: whenever -- an item starts being edited, the behavior records the ticks in -- myStartTicks. On each exitFrame, it compares the time since -- myStartTicks with the movie property the lastClick. If the user has -- clicked the mouse since the item became editable, the exitFrame -- then checks if the click was on a different sprite. To do this it -- compares the spriteNum with the clickOn. This two-tiered test is -- necessary, since using TAB or RETURN to jump to a different -- editable item does not alter the clickOn. -- TABBING TO SPRITES WITH THE SAME BEHAVIOR -- How does the beahvior know which sprite to jump to? The Intialize -- handler (called on beginSprite) asks all similar behaviors to add -- themselves to a shared list: ourGroupList. The EditNextField -- handler steps through this list, returning to the beginning if -- necessary. Tabbing to the next editable item invokes the -- StartEditing me handler, just as a mouseUp does. -- LINGO ACCESS TO THE VALUE OF THE ITEM -- If you need to know the value of an item, you can use one of two -- techniques: -- * The orthodox technique is to use a sendSprite call: -- -- put sendSprite (, #NumericalValue) -- -- This will return a list of the form: -- -- [#unit: "$", #value: 10.0000] -- -- * The less rigorous method is to ask for the sprite's value -- directly: -- -- put sprite().myValue -- -- If you want the value of all sprites with this behavior, use a -- sendAllSprites call, either with a linear list or a property list -- as a parameter. A property list will return the sprite numbers as -- properties: -- -- put sendAllSprites (#NumericalValue, [:]) -- [1: [#unit: "£", #value: 12.20], 2: [#unit: "$", #value: -10.0000]] -- TROUBLE SHOOTING -- Symptom: Cents do not appear correctly after the decimal point. -- Open the Behavior Parameters dialog and choose the appropriate -- format in the "Show cents" pop-up menu. Ensure that the correct -- character has been chosen for the decimal point. (Certain -- countries use commas instead of a dot). -- Symptom: Numbers are truncated -- Ensure that no unwanted non-numeric characters have been included -- in the list of allowed characters. Check specifically for the -- letters "O" and "l" which can be confused with zero and one. -- Symptom: numbers over 95 (US) trillion are incorrectly displayed. -- This is due to the limit of accuracy of floating point numbers. -- Numbers can only be displayed to an accuracy of 14 decimal places. -- If you need to work with numbers over 95 000 000 000 000 then -- perhaps you should consider treating them in larger chunks: for -- example "95 000 000 million". You can even use a string such as -- "$ (millions)" as your currency symbol. -- PROPERTIES -- property spriteNum -- error checking property getPDLError -- author-defined parameters property myCurrencySymbol property mySymbolFirst property myCentsByDefault property myDecimalDelimiter property myThousandsDelimiter property myEditable property myValidCharacters -- internal properties property myMember property myStartTicks property myValue -- shared properties property ourGroupList -- EVENT HANDLERS -- on beginSprite me Initialize me end beginSprite on exitFrame me if not the editable of myMember then exit if the lastClick > (the ticks - myStartTicks) then exit if the clickOn <> spriteNum then -- USER CLICKED ELSEWHERE ApplyFormat me end if end exitFrame on mouseUp me StartEditing me end mouseUp on keyDown me FilterInput me end keyDown on endSprite me ourGroupList.deleteOne(me) end endSprite -- CUSTOM HANDLERS -- on Initialize me -- sent by beginSprite myMember = sprite(me.spriteNum).member -- Error checking if not voidP (getPDLError) then ErrorAlert (me, #getPDL_Invalid, myMember.type) end if if myDecimalDelimiter = myThousandsDelimiter then ErrorAlert (me, #delimiterClash, myDecimalDelimiter) end if -- End of error checking ourGroupList = [] sendAllSprites (#NumberFormat_RollCall, ourGroupList) -- Convert properties choices = GetPDLChoices (me) mySymbolFirst = (mySymbolFirst = choices.position.getAt(1)) case choices.cents.getPos(myCentsByDefault) of 1: myCentsByDefault = #always 2: myCentsByDefault = #ifPresent 3: myCentsByDefault = #never 4: myCentsByDefault = #round end case case myThousandsDelimiter of "SPACE": myThousandsDelimiter = SPACE "no separation": myThousandsDelimiter = EMPTY end case ApplyFormat me myMember.editable = myEditable end Initialize on FilterInput me -- sent by keyDown theKey = the key if theKey = RETURN or theKey = TAB then -- The user validated the input ApplyFormat me EditNextField me else if myValidCharacters&BACKSPACE contains theKey then -- Allow valid characters... if theKey = myDecimalDelimiter and \ myMember.text contains myDecimalDelimiter then --... but only one decimal point beep else if theKey = "-" then -- Toggle the negative sign theText = myMember.text theSelStart = the selStart theSelEnd = the selEnd if theText.char[1] = "-" then delete theText.char[1] counter = 1 repeat while theText.char[1] = SPACE delete theText.char[1] counter = counter + 1 end repeat the selStart = max(0, theSelStart - counter) the selEnd = max(0, theSelEnd - counter) myMember.text = theText else put "- " before theText myMember.text = theText the selEnd = theSelEnd + 2 the selStart = theSelStart + 2 end if else pass end if else if [117, 123, 124, 125, 126].getPos (the keyCode) then -- Allow "suppr" and the arrow keys pass else -- Refuse illicit input beep end if end FilterInput on ApplyFormat me -- sent by exitFrame, FilterInput myValue = GetValue (me, myMember.text) myMember.text = MoneyFormat (me, myValue) myMember.editable = FALSE end ApplyFormat on GetValue me, numberString -- sent by Initialize, ApplyFormat if numberString = EMPTY then return 0 if myDecimalDelimiter = "." then initialValue = value (numberString) if initialValue = numberString then return initialValue end if end if negative = FALSE theValue = 0 figuresAfterPoint = 0 strippedString = "" counter = the number of chars of numberString repeat with theChar = 1 to counter nextChar = char 1 of numberString delete char 1 of numberString if value (nextChar) or nextChar = "0" then strippedString = strippedString&nextChar else case nextChar of myDecimalDelimiter: strippedString = strippedString&"." "-": negative = TRUE otherwise end case end if end repeat if negative then return -value (strippedString) else return value (strippedString) end if end GetValue on MoneyFormat me, aNumber -- sent by ApplyFormat negative = aNumber < 0 aNumber = abs (aNumber) if ilk (aNumber) = #float then saveDelimiter = the itemDelimiter the itemDelimiter = myDecimalDelimiter savePrecision = the floatPrecision the floatPrecision = 2 aNumber = string (aNumber) the floatPrecision = savePrecision units = aNumber.item[1] cents = value (aNumber.item[2]) the itemDelimiter = saveDelimiter else -- if ilk (aNumber) = #integer then units = string (aNumber) cents = 0 end if -- Add zeros if required case myCentsByDefault of #always: addZeros = TRUE #ifPresent: addZeros = cents #never: addZeros = FALSE #round: unitValue = value (units) -- (the last char of units) if cents = 50 then -- Round to nearest even units cents = cents + (unitValue mod 2) end if unitValue = unitValue + (cents > 50) units = string(unitValue) addZeros = FALSE end case if addZeros then cents = string (cents) repeat while length (cents) < 2 set cents = "0"¢s end repeat end if unitString = ThousandsFormat (me, units) if cents = "0" or (not stringP (cents)) then moneyString = unitString else moneyString = unitString&myDecimalDelimiter¢s end if -- Construct return string returnString = "" if negative then returnString = returnString&"- " end if if myMember.type = #text then -- Include a TAB to align decimal points returnString = returnString&TAB end if if mySymbolFirst then returnString = returnString&myCurrencySymbol&moneyString else returnString = returnString&moneyString&myCurrencySymbol end if return returnString end MoneyFormat on ThousandsFormat me, unitString -- sent by MoneyFormat if not value (unitString) then return "0" if myThousandsDelimiter = EMPTY then return unitString thousandsLength = unitString.char.count - 3 repeat while thousandsLength > 0 put myThousandsDelimiter after char thousandsLength of unitString thousandsLength = thousandsLength - 3 end repeat return unitString end ThousandsFormat on EditNextField me -- sent by FilterInput position = ourGroupList.getPos(me) if position = ourGroupList.count() then call (#StartEditing, ourGroupList[1]) else call (#StartEditing, ourGroupList[position + 1]) end if end EditNextField on StartEditing me -- sent by mouseUp, called by EditNextField from another behavior myMember.editable = myEditable myStartTicks = the ticks end StartEditing -- PUBLIC METHODS (responses to #sendSprite, #sendAllSprites, #call) -- on NumericalValue me, theList valueList = [#unit: myCurrencySymbol, #value: myValue] case ilk (theList) of #propList: theList.addProp (spriteNum, valueList) return theList #list: theList.append (valueList) return theList otherwise return valueList end case end NumericalValue on NumberFormat_RollCall me, groupList -- called by Initialize from another behavior ourGroupList = groupList groupList.append(me) end NumberFormat_RollCall -- ERROR CHECKING -- on ErrorAlert me, theError, data -- sent by getPropertyDescriptionList, Initialize -- Determine the behavior's name behaviorName = string (me) delete word 1 of behaviorName delete the last word of behaviorName delete the last word of behaviorName -- Convert #data to useful value& case data.ilk of #void: data = "" #symbol: data = "#"&data end case case theError of #getPDLError: beep return \ [ \ #getPDLError: \ [ \ #comment: "CANCEL: Sprite MUST contain one of the following member types: "&\ RETURN&data, \ #format: #symbol, \ #range: [#Cancel], \ #default: #Cancel \ ] \ ] #getPDL_Invalid: alert \ "BEHAVIOR ERROR: Frame "&the frame&", Sprite "&me.spriteNum&RETURN&RETURN&\ "Behavior "&behaviorName&" only functions with the following member types:"&\ RETURN&permittedMemberTypes()&RETURN&RETURN&\ "Current type = "&data halt #delimiterClash: alert \ "BEHAVIOR ERROR: Frame "&the frame&", Sprite "&me.spriteNum&RETURN&\ "Behavior "&behaviorName&RETURN&RETURN&\ "Both the decimal point and the thousands separator \ currently use the character: ""E&data"E&RETURN&\ "Choose distinct characters in the Behavior Parameters dialog." halt end case end ErrorAlert -- AUTHOR-DEFINED PARAMETERS -- on getPropertyDescriptionList me if not the currentSpriteNum then exit -- Error check: does current sprite contain appropriate member type? -- (Frame behaviors need a different check) theMember = sprite(the currentSpriteNum).member memberType = theMember.type permittedTypes = PermittedMemberTypes(me) if not permittedTypes.getPos(memberType) then return errorAlert (me, #getPDLError, permittedTypes) end if choices = GetPDLChoices (me) return \ [ \ #myCurrencySymbol: \ [ \ #comment: "Currency symbol:", \ #format: #string, \ #default: "$" \ ], \ #mySymbolFirst: \ [ \ #comment: "Place currency symbol:", \ #format: #string, \ #range: choices.position, \ #default: choices.position.getAt(1) \ ], \ #myCentsByDefault: \ [ \ #comment: "Show cents:", \ #format: #string, \ #range: choices.cents, \ #default: choices.cents.getAt(1) \ ], \ #myDecimalDelimiter: \ [ \ #comment: "Character used for decimal point:", \ #format: #string, \ #range: [".", ","], \ #default: "." \ ], \ #myThousandsDelimiter: \ [ \ #comment: "Character used for separating thousands:", \ #format: #string, \ #range: ["SPACE", ".", ",", "no separation"], \ #default: "SPACE" \ ], \ #myEditable: \ [ \ #comment: "Allow users to edit numbers?", \ #format: #boolean, \ #default: TRUE \ ], \ #myValidCharacters: \ [ \ #comment: "Allow only the following characters:", \ #format: #string, \ #default: "1234567890. -$" \ ] \ ] end getPropertyDescriptionList on PermittedMemberTypes me -- sent by: -- getBehaviorDescription -- getPropertyDescriptionList -- Initialize return [#field, #text] end PermittedMemberTypes on GetPDLChoices me -- sent by getPropertyDescriptionList, Initialize return \ [ \ #position: \ [ \ "before sum ($ 10.00 )", \ "after sum ( 10.00 FF)"\ ], \ #cents: \ [ \ "always ($10.00, $ 9.99)", \ "only if present ($10, $9.99)", \ "never ($10, $9 )", \ "round figures ($10, $10 )" \ ] \ ] end GetPDLChoices on getBehaviorTooltip me return \ "Use with Field and Text members."&RETURN&RETURN&\ "Display currency and other"&RETURN&\ "numbers in customized formats."&RETURN&\ "Handles positive and negative"&RETURN&\ "values up to 95 trillion (US)."&RETURN&RETURN&\ "Use TAB or RETURN to jump to"&RETURN&\ "the next editable sprite with"&RETURN&\ "the same behavior." end getBehaviorTooltip on getBehaviorDescription me return \ "FORMAT NUMBERS"&RETURN&RETURN&\ "This behavior enables you to display numbers and monetary values in a variety of formats. It handlers positive and negative values up to, but not including 95 trillion (95 UK billion)."&RETURN&RETURN&\ "The exact value of the number is stored as a property of the behavior. A #NumericalValue call to the sprite will return this value as a property list of the form:"&RETURN&RETURN&\ " [#unit: ""E&"$""E&", #value: 10.00]"&RETURN&RETURN&\ "This allows you to perform calculations based on the value, regardless of the display format. You can even use the #unit property as a parameter in currency conversion calculations."&RETURN&RETURN&\ "Numbers may be edited by the user. The chosen format will be applied when the user tabs to the next field or clicks elsewhere."&RETURN&RETURN&\ "The minus key '-' works as a toggle, either adding or removing a minus sign from in front of the number."&RETURN&RETURN&\ "PERMITTED MEMBER TYPES:"&RETURN&PermittedMemberTypes (me)&RETURN&RETURN&\ "PARAMETERS:"&RETURN&\ "• Currency symbol ($, £, DM, F, ...). If no currency is to be displayed, leave the 'Currency symbol' field empty."&RETURN&\ "• Position of currency symbol (before | after sum)"&RETURN&\ "• Show zero cents as '.00'?"&RETURN&\ "• Character used to separate cent values (default = '.')"&RETURN&\ "• Character used to separate thousands (default = SPACE)"&RETURN&\ "• Allow users to edit the number (TRUE | FALSE)"&RETURN&\ "• List of permitted characters "&RETURN&\ "The behavior will use this list to filter keyboard input and beep if an illegal character is entered by mistake."&RETURN&RETURN&\ "TIP:"&RETURN&\ "Field members - use right alignment"&RETURN&\ "Text members - set a decimal tab." end getBehaviorDescription