Up

Extending the PersonalMoney Application

 

Part 6

 

 

Objectives and Overview

 

This exercise revisits binary filing to put in versioning that should have gone in before, addressing multiple versions of the application.  A simple import and export facility is built to transfer data using the Quicken Interchange Format (qif).  The editable combo box is expanded to search for partial user-typed entries.

 

 

Binary filing revisited

 

In Part 4, as part of implementing bi-directional sorting, we looked at versioning instances of ListViewColumn.  We changed the class definition, adding an instance variable, then overrode two class-side methods, #stbVersion and #stbConvertFrom:, so that instances previously filed out could be reloaded with the binary filer (STBFiler).  While that's well and good, we did not do the same for PersonalMoney's various model classes which have gone through internal structural changes since the original Object Arts Tutorial.

 

In Part 1 we added 'accountTypes' to PersonalMoney and 'accountType' to PersonalAccount.  In Part 5 we added 'categories' to PersonalAccount and 'category' to PersonalAccountTransactions.  This means that if you had created some data in the original PersonalMoney application and then filed it out, you could not read it into the current version of PersonalMoney.  Likewise, if you had created some data in the version following the Part 1 changes, you could not read it in now.  In the case of PersonalMoney data this might not be a big thing, but if this had been a deployed application, you might be getting calls from different people saying "What gives ... and where's the fix?" and in fact you'd need to be fixing each and every versioning problem.

 

There's a third versioning problem and that's for users who created data files with Dolphin version 2.1.  Although this tutorial is geared for version 3 and above, it's possible that someone with version 2.1 created data, saved it, and now wants to read that into the latest PersonalMoney application.  Again, for a deployed application, this is a reasonable scenario and something you'd have to deal with.  Here are the instance variables for class PersonalMoney for the various stages.

 

PersonalMoney, instance variables at various stages

Tutorial Version

Dolphin Version

class

#stbVersion

Slot 1

Slot 2

Slot 3

Slot 4

Original

2.1

0

owner

accounts

 

 

Original

3

1

events

owner

accounts

 

Part 1

3

2

events

owner

accounts

accountTypes

 

This shows that in the original tutorial, PersonalMoney had two instance variables -- owner and accounts.  Dolphin version 3 added 'events' in class Model, so that is inherited by PersonalMoney -- in slot 1.  Then in Part 1 of this tutorial we added 'accountTypes'.  These different versions of the model are indicated by the column 'class #stbVersion', though we haven't implemented version 2 yet.  #stbVersion 0 is the default and that is what's found in Dolphin 2.1.  Dolphin 3 sets stbVersion to 1 and this is what is inherited by PersonalMoney so far.

 

Let's say we have a data file from Dolphin version 2.1.  If we just read it back into Dolphin 2.1, the version is zero and no conversion takes place.  The binary filer reads in the instance, finds two values, creates a PersonalMoney instance and assigns the two values to the two instance variables.  As for reading the same file into Dolphin verion 3, take a quick look at the conversion method in class Model, Dolphin 3:

 

Model, class side:

stbConvertFrom: anSTBClassFormat

        "Convert from earlier version models.

        1: Added 'events' instance variable to Model."

 

        ^[:data | | newInst |

                 newInst := self basicNew.

                 data keysAndValuesDo: [:i :v | newInst instVarAt: i+1 put: v].

                 newInst]

 

In this case, the filer reads in two values, but the new instance will have three slots.  The method above essentially rightshifts the values.  'data' is an array that contains the values read in by the binary filer.  The first value goes into slot 2 (owner) and the second value goes into slot 3 (accounts).  'events' is left at nil, which is a valid value (for events), so no initialization is needed.

 

The key things to remember when overriding #stbConvertFrom: are that a block is returned.  The block will be passed an argument, 'data', an array containing the values in the binary file, which may or may not represent an old class version.  The code in the block needs to create a new blank instance of the receiver (the model), copy the data values from 'data' into the new instance, initialize or change values in the new instance as needed, and then return the new instance.

 

So what changes do we want to make for the next version, version 2.  We want to use the existing code so that version 0 data will be rearranged and copied (from data to the newInst) and so that version 1 data will be copied from data to the newInst.  Then, since the 'data' array will not have any values for 'accountTypes', but newInst does have a slot for it, we need to initialize the value in slot 4.  This looks like so:

 

In PersonalMoney, class side:

stbVersion

        "Answer the current binary filer version number for instances of the receiver."

 

        ^2

 

stbConvertFrom: anSTBClassFormat

        "Convert from earlier version models.

        1: Added 'events' instance variable to Model.

        2: Added 'accountTypes' instance variable."

 

        ^[:data | | newInst ver offset |

                 newInst := self basicNew.

                 ver := anSTBClassFormat version.

                 offset := ver < 1 ifTrue: [1] ifFalse: [0].

                 1 to: data size do: [:i | newInst instVarAt: i+offset put: (data at: i)].

                 "Perhaps use lazy initialization instead of the next statement."

                 newInst accountTypes isNil ifTrue: [

                         newInst instVarAt: 4 put:

                                 (ListModel with: OrderedCollection new searchPolicy: SearchPolicy equality)].

                 newInst]

 

A few notes:  We add a line to the comment, indicating the change(s) for this version.  We extract the version ('ver') from anSTBClassFormat, which is passed in as a parameter.  We set up an 'offset' local variable which we use to shift the data values into the new instance variable, if needed.  If version is zero, data will be shifted, otherwise it was already shifted before writing out to file.  Then we copy the values from the data array into the appropriate slots in newInst.  At this point we test to see if newInst accountTypes isNil, which it will be if we're reading in data filed from versions 0 or 1, so we initialize it here.  Note that we need to use the method #instVarAt:put: since there's no public method in PersonalMoney to assign accountTypes.

 

Note also the comment on Lazy Initialization.  My first thoughts were to leave accountTypes uninitialized, simplifying this method.  Then move the initialization code for accountTypes from PersonalMoney>>#initialize to the accountTypes accessor method.  However, following the suggestion and reasoning in the Pattern for Lazy Initialization, I put the code in here, duplicating what is in #initialize.  I do still wonder about this -- I know there are those who swear by Lazy Initialization.

 

Now we look at PersonalAccountTransaction, which is very similar to PersonalMoney.

 

PersonalAccountTransaction, instance variables at various stages

Tutorial Version

Dolphin Version

class

#stbVersion

Slot 1

Slot 2

Slot 3

Slot 4

Slot 5

Slot 6

Original

2.1

0

date

description

amount

isDebit

 

 

Original

3

1

events

date

description

amount

isDebit

 

Part 5

3

2

events

date

description

amount

isDebit

category

 

Version 0 had 4 instance variables. Version 1 (Dolphin verion 3) added an 'events' instance variable and verion 2 (tutorial Part 5) added a 'category' instance variable.  So we essentially do the same thing here that we did in PersonalMoney class>>#stbConvertFrom: except that since category can be nil, we don't even bother initializing it.  Here are the new methods:

 

In PersonalAccountTransaction, class side:

stbVersion

        "Answer the current binary filer version number for instances of the receiver."

 

        ^2

 

stbConvertFrom: anSTBClassFormat

        "Convert from earlier version models.

        1: Added 'events' instance variable to Model.

        2: Added 'category' instance variable."

 

        ^[:data | | newInst ver offset |

                 newInst := self basicNew.

                 ver := anSTBClassFormat version.

                 offset := ver < 1 ifTrue: [1] ifFalse: [0].

                 1 to: data size do: [:i | newInst instVarAt: i+offset put: (data at: i)].

                 ver < 2 ifTrue: ["leave 'category' nilled."].

                 newInst]

 

Finally, we look at PersonalAccount, which is similar to the previous two model classes, though a bit more complex.

 

PersonalAccount, instance variables at various stages

Tutorial Version

Dolphin Version

#stb

Version

Slot 1

Slot 2

Slot 3

Slot 4

Slot 5

Slot 6

Slot 7
Slot 8

Original

2.1

0

name

account

Number

initial

Balance

transactions

current

Balance

 

 

 

Original

3

1

events

name

account

Number

initial

Balance

transactions

current

Balance

 

 

Part 1

3

2

events

name

account

Number

account

Type

initial

Balance

transactions

current

Balance

 

Part 5

3

3

events

name

account

Number

account

Type

initial

Balance

transactions

current

Balance

categories

 

PersonalAccount started out with 5 instance variables in version 0 (Dolphin 2.1).  Version 1 saw the addition of 'events', and then 'accountType' was added in Tutorial Part 1 and 'categories' was added in Tutorial Part 5.  Note that 'accountType' was added in the middle of the list of instance variables (slot 3), rather than at the beginning or the end -- this complicates things a bit but it's not too bad.

 

In PersonalAccount, class side:

stbVersion

        "Answer the current binary filer version number for instances of the receiver."

 

        ^3

 

stbConvertFrom: anSTBClassFormat

        "Convert from earlier version models.

        1: Added 'events' instance variable to Model.

        2. Added 'accountType' instance variable.

        3: Added 'categories' instance variable."

 

        ^[:data | | newInst ver offset |

                 newInst := self basicNew.

                 ver := anSTBClassFormat version.

                 "Copy values from data to newInst.  Shift by 1, for events, if ver = 0."

                 offset := ver < 1 ifTrue: [1] ifFalse: [0].

                 1 to: data size do: [:i | newInst instVarAt: i+offset put: (data at: i)].

 

                 (ver < 2 and: [(newInst instVarAt: 5) isLiteral not]) ifTrue: [

                         "shift data and nil out 'accountType' in 4th slot."

                         self instSize-1 to: 4 by: -1 do: [:i | newInst instVarAt: i+1 put: (newInst instVarAt: i)].

                         newInst instVarAt: 4 put: nil].

                 ver < 3 ifTrue: ["initialize categories."

                         newInst instVarAt: 8 put:

                                 (ListModel with: SortedCollection new searchPolicy: SearchPolicy equality)].

                         newInst]

 

Most of this is pretty much the same as in the previous two model classes.  The tricky part is the section starting with "(ver < 2 and: [(newInst instVarAt: 5) isLiteral not])".  If someone had saved data in Dolphin 2.1 or Dolphin 3 (before the changes in Tutorial Part 1) then we want to shift initialBalance, transactions, and currentBalance one slot to the right to make room for accountType.  However, if they had saved data after making the changes in Tutorial Part 1, the saved file would have a version of 1, but it would have accountType data in the proper slot.  #isLiteral returns false if transactions data (a ListModel) is in the fifth slot, in which case we do need to shift the columns to the right, then nil out the old data in the fourth slot (accountType).  Note that we go backwards, using #to:by:do:, decrementing by 1 (by: -1) to perform the right-shift.

 

At this point, you can read in almost any file that was created in a prior version of PersonalMoney.  The exception is those files with sorted data that were created prior to the fix to #stbSaveOn: (in Part 3).  By this I mean the case where a listview column is clicked (which sorts the data) and then the data is saved to file.  These files, as far as I can see, are not readable.  I won't even go into what I've tried -- if you look (using a hex editor or even notepad) at a binary file with the data sorted, you'll see a whole shipload of stuff, I assume brought in by a block closure or two.  The fix in Part 3 prevents the problem but does not offer a solution.  It seems like somewhere it would be necessary to bypass all the gunk, picking out the data and making it an OrderedCollection, but I couldn't figure out the where or how.

 

The bottom line AND the big lesson on all of this is that if you're using the binary filer, make sure you update the #stbVersion and #stbConvertFrom: class-side methods whenever changes are made to the class definition.  Also, make sure you use #stbSaveOn: to convert any sorted collections back to ordered collections before filing them out.

 

 

Streams, FileStreams and QIF Data

 

In brief, a stream (usually a ReadStream, WriteStream or FileStream) provides a simple and consistent protocol that allows you to access an underlying sequenceable collection (string, file, or byte array).  An excellent primer on Smalltalk streams can be found in the IBM Smalltalk Tutorial, Chapter 8.  The Dolphin hierarchy of stream and filestream classes differs from that of IBM, but there is effectively little difference -- most of the methods and usage are the same.

 

The minimum requirement for qif file transfer (in and out of PersonalMoney) is to be able to write an account's transactions to file so that it can be read into Quicken and also to be able to read an account's transactions that were written to file from within Quicken. 

 

Below is a file (22 lines) that was generated by Quicken.  The account has an opening balance of $500 and two transactions.  The first line is a Quicken 'export header' and identifies the account type, in this case a Bank account.  This is followed by three transactions, including the opening balance.  Each transaction consists of a number of lines ending with a circumflex (^).  Each line starts with a single character, identifying the type of information, and then the information itself.

 

!Type:Bank

D8/11/99

U500.00

T500.00

CX

POpening Balance

L[Wells Fargo]

^

D3/ 3' 0

U-80.00

T-80.00

N101

PBell Market

LGroceries

^

D9/15' 0

U100.00

T100.00

NATM

PDeposit

LReimbursement

^

 

The single character codes have the following meanings:  'D' stands for date, 'U' and 'T' stand for amount (I'm not sure what the difference is, the Quicken documentation is unclear), 'C' stands for transaction cleared, 'P' stands for payee or description, 'L' stands for transaction category, and 'N' stands for the check number or reference.

 

For now we can ignore some of these lines and just process the ones we're interested in.  Specifically, we look for date, amount, description, category, and end of item.  These correspond to codes D, T, P, L, and ^.  These are the items we'll read in to the model as well as write out.  Export headers, check numbers, 'U' amounts, and other coded information we'll make use of at a later date.

 

Starting with the export side of the business, look at #fileSave and #fileSaveAs in DocumentShell for some ideas on how to prompt for a filename, then save data to file.  These methods use FileSaveDialog to prompt for the filename -- if it's not nil then a stream is opened on the file, another method is called to write the data out, and the file is closed.  An exception is raised if there is a problem with the file.  Combining these for our purposes, we get:

 

In PersonalAccountShell:

accountExport

        "Prompts for a filename, then exports the receiver to file."

 

        | filename stream |

        filename := (FileSaveDialog on: self model name)

                 fileTypes: #( ('Quicken Files (*.qif)' '*.qif') ); defaultExtension: 'qif';

                 caption: 'Export Document As'; showModal.

        [filename notNil ifTrue: [

                 stream := FileStream write: filename.

                 [self streamOutQIF: stream] ensure: [stream close]]

        ] on: FileException do: [:e |

                 MessageBox errorMsg: 'Unable to export file ',

                         e file name printString caption: 'Error - ', e messageText]

 

This method creates a FileStream on the user-supplied filename, then tells itself to stream out qif data to that stream, ensuring that the stream is closed (upon success or failure).  The first cut at implementing #streamOutQIF: is simple -- just have each transaction stream itself to the filestream:

 

In PersonalAccountShell:

streamOutQIF: aStream

        "Write receiver's transactions out to aStream."

 

        self transactions do: [:trans | trans streamOutQIF: aStream]

 

In PersonalAccountTransaction:

streamOutQIF: aStream

        "Write receiver's data out to aStream in Quicken (QIF) format."

 

        aStream

                 nextPut: $D; nextPutAll: self date displayString; cr;

                 nextPut: $T; nextPutAll: self actualAmount displayString; cr;

                 nextPut: $P; nextPutAll: self description; cr;

                 nextPut: $L; nextPutAll: self category; cr;

                 nextPut: $^; cr

 

This is close.  If you import this into Quicken, it works fine except the dates are not recognized.  Quicken seems to want the dates in MM/DD/YY format.  The other thing is that the account's initial balance is not written to file, which is a problem if the initial balance is not zero.

 

The date problem we solve using a DateToText converter.

 

In PersonalAccountTransaction:

streamOutQIF: aStream

        "Write receiver's data out to aStream in Quicken (QIF) format."

 

        aStream

                 nextPut: $D; nextPutAll: (DateToText new format: 'MM/dd/yy';

                         convertFromLeftToRight: self date); cr;

                 nextPut: $T; nextPutAll: self actualAmount displayString; cr;

                 nextPut: $P; nextPutAll: self description; cr;

                 nextPut: $L; nextPutAll: self category; cr;

                 nextPut: $^; cr

 

The missing initial balance we add by creating a temporary transaction and then filing it out.  The date we assign to it is the earliest transaction date.

 

In PersonalAccountShell:

streamOutQIF: aStream

        "Write receiver's transactions out to aStream."

 

        (PersonalAccountTransaction new description: 'Opening Balance';

                         amount: self model initialBalance; isDebit: self model initialBalance < 0;

                         date: (self model transactions size > 0

                                 ifTrue: [self model transactions first date]

                                 ifFalse: [Date today]);

                         category: '[', self model name, ']')

                 streamOutQIF: aStream.

        self model transactions do: [:trans | trans streamOutQIF: aStream]

 

At this point you can add some menu entries to PersonalAccountShell, read in an old file and export it.  Add menu commands 'Import' and 'Export' to the 'Account' menu.  The command names, corresponding to methods in PersonalAccountShell, are #accountImport and #accountExport.  Shown here is the menu composer for 'Import':

 

 

 

To import qif data, we want to prompt for a filename (as we did with accountExport) and then, if it's not nil, open a stream on it and read the stream of qif transactions into the account.  Import is a bit more complex than export since we have to parse the input stream, use or discard each line, and assign and convert values as appropriate.  The starting method, #accountImport, is very similar to accountExport.  The parsing et al is done in #streamInQIF:.

 

In PersonalAccountShell:

accountImport

        "Prompts for a file and imports the file's contents into the receiver."

 

        | filename stream |

        filename := FileOpenDialog new

                 fileTypes: #( ('Quicken Files (*.qif)' '*.qif') ); defaultExtension: 'qif';

                 showModal.

        [filename notNil ifTrue: [

                 stream := FileStream read: filename.

                 [self streamInQIF: stream] ensure: [stream close]]

        ] on: FileException do: [:e |

                 MessageBox errorMsg: 'Unable to import file ', e file name printString caption: 'Error - ', e messageText]

 

streamInQIF: stream

        "Private - read in and add transactions to the receiver's model from the qif stream."

 

        | trans code line |

        trans := PersonalAccountTransaction new.

        [stream atEnd] whileFalse: [

                 code := stream next.

                 line := stream nextLine.

                 code = $D ifTrue: [trans date: (DateToText new format: 'MM/dd/yy';

                         convertFromRightToLeft: line)].

                 code = $T ifTrue: [trans amount: line asNumber abs.  trans isDebit: line asNumber < 0].

                 code = $P ifTrue: [trans description: line].

                 code = $L ifTrue: [trans category: line].

                 code = $^ ifTrue: [self model addTransaction: trans.  trans := PersonalAccountTransaction new]]

 

In the above method, we read a line at a time from the stream (a FileStream) till the end of stream is reached.  The first character of the line read in goes into 'code' and the rest of the line goes into 'line'.  Each line ends in a CR-LF so we use #nextLine to extract the string from the second character up to but not including the CR-LF.  (I would have preferred to use 'stream upTo: String lineDelimiter' which appears as though it should return the same, discarding the CR-LF, but #upTo: expects a single Character, despite its comment indicating otherwise.)  We test to see what the code is and then use the information in 'line' to set the corresponding data value in 'trans', which contains a new transaction.  If the code is '^', this indicates the end of data for the current transaction, so we add 'trans' to the model and create a new blank transaction.

 

This method still needs a few additions:

 

  1. It will not read the correct year for data prior to 2000.  Remember that Quicken data uses a 2-digit year.  By default, Class DateToText will interpret a 2-digit year such as '00' as 1999.  A class method #yearPivot: can be used to change this -- the default value is zero.  A 2-digit year that is less than the pivot is interpreted as being in the current century.  Thus, with a yearPivot of 50, '50 through '99' means 1950-1999 and '00' through '49' means 2000-2049.  We change the method to store and restore the old pivotYear and use a pivotYear of 50 within the method.

 

  1. Some amounts need to be parsed further.  Quicken writes amounts with a comma separating thousands (e.g., 2,000).  Currently, we apply #asNumber to 'line' which will just truncate the amount if there's a comma in it -- e.g., '2,000' becomes 2.  #asNumber calls Number>>#fromString: which in turn calls #readFrom: which does not handle thousands separators.  So we put in a simple phrase that will strip out commas from 'line' -- line select: [:char | char ~= $,].

 

  1. Special treatment is needed for an 'Opening Balance' transaction.  This is the reverse of what we have in accountExport.  Instead of adding it as another transaction to the model, we assign the amount to the model's initial balance.

 

The revised method, with these changes, looks like so:

 

In PersonalAccountShell:

streamInQIF: stream

        "Private - read in and add transactions to the receiver's model from the qif stream."

 

        | trans code line oldPivot |

        oldPivot := DateToText yearPivot.

        DateToText yearPivot: 50.

        trans := PersonalAccountTransaction new.

        [stream atEnd] whileFalse: [

                 code := stream next.

                 line := stream nextLine.

                 code = $D ifTrue: [

                         trans date: (DateToText new format: 'MM/dd/yy'; convertFromRightToLeft: line)].

                 code = $T ifTrue: [

                         trans amount: (line select: [:char | char ~= $,]) asNumber abs.

                         trans isDebit: line asNumber < 0].

                 code = $P ifTrue: [

                         trans description: line].

                 code = $L ifTrue: [

                         trans category: line].

                 code = $^ ifTrue: [

                         trans description = 'Opening Balance'

                                 ifTrue: [self model initialBalance: trans actualAmount]

                                 ifFalse: [self model addTransaction: trans].

                         trans := PersonalAccountTransaction new]].

        DateToText yearPivot: oldPivot

 

It should work now on a file that's exported from Quicken.  The only things I'll point out are that the method has grown considerably (we do like little simple methods, don't we) and the tests for code seem duplicative, that is, a switch or case statement seems to be in order there.  One thing to do, which will take care of both issues, is to break the method in two, like so:

 

streamInQIF: stream

        "Private - read in and add transactions to the receiver's model from the qif stream."

 

        | trans code line oldPivot |

        oldPivot := DateToText yearPivot.

        DateToText yearPivot: 50.

        trans := PersonalAccountTransaction new.

        [stream atEnd] whileFalse: [

                 code := stream next.

                 line := stream nextLine.

                 trans := self parseQIFCode: code on: trans data: line].

        DateToText yearPivot: oldPivot

 

parseQIFCode: code on: trans data: line

        "Private - processes code and line on trans.  Returns a trans."

 

        code = $D ifTrue: [

                 ^trans date: (DateToText new format: 'MM/dd/yy'; convertFromRightToLeft: line)].

        code = $T ifTrue: [

                 trans amount: (line select: [:char | char ~= $,]) asNumber abs.

                 ^trans isDebit: line asNumber < 0].

        code = $P ifTrue: [

                 ^trans description: line].

        code = $L ifTrue: [

                 ^trans category: line].

        code = $^ ifTrue: [

                 trans description = 'Opening Balance'

                         ifTrue: [self model initialBalance: trans actualAmount]

                         ifFalse: [self model addTransaction: trans].

                 ^PersonalAccountTransaction new].

        ^trans

 

 

Simple Currency Formatting

 

The next addition to the application has nothing to do with qif import or export, but has to do with formatting.  I've been using integers for the 'amount' so far in all my examples, so they line up nicely in the pictures you see, but we really want them to show as dollars and cents.  Once again, I apologize if dollars and cents isn't appropriate for your locale.

 

There are probably a dozen or more ways to do this, but I didn't find anything that is simple, so I decided to roll my own simple methods.  In class Float I added a method #asSimpleCurrency which just formats a float to two decimal places.  In Number, I added #asSimpleCurrency which just calls Float>>#asSimpleCurrency.  There's no fancy formatting here, no thousands separators -- the focus is on having 2 decimal places so that the numbers line up properly on the right.

 

In Float:

asSimpleCurrency

        "Returns the receiver as a string with 2 decimal places."

 

        | str |

        str := String writeStream.

        self printOn: str decimalPlaces: 2.

        ^str contents

 

In Number:

asSimpleCurrency

        "Returns the receiver as a string with 2 decimal places."

 

        ^self asFloat asSimpleCurrency

 

We need to make a corresponding change to the view, adding a block that will convert the amount from a number to a display string.  In PersonalAccountShell's listview, change the GetTextBlock for the debit and credit columns to: [:num | num isNil ifTrue: [''] ifFalse: [num asSimpleCurrency]].  After importing a file in qif format, this is what I got:

 

 

 

Ok, there are a few things I did before I printed this picture.  First I noticed that the Balance was 5090.090, showing three decimal places (acutally, in an inspector it was 5090.000000007) so some simple currency conversion is needed here.  The Balance field is a Static Text that contains a TextConverter.  We can create a new TextConverter by subclassing NumberToText, creating NumberToSimpleCurrency beneath it.  Then just override one method to return a simple currency formatted number.

 

NumberToText subclass: #NumberToSimpleCurrency

        instanceVariableNames: ''

        classVariableNames: ''

        poolDictionaries: ''

 

In NumberToSimpleCurrency:

leftToRight: aNumber

        "Answers the result of converting aNumber to a simple currency format"

 

        ^aNumber asSimpleCurrency

 

Bring up VC now on PersonalAccountShell's Default view and select the Balance Static Text.  In the right pane of the PAI, select and evaluate the following:

 

            self typeconverter: NumberToSimpleCurrency new

 

You might also want to enlarge the columns for debit and credit to allow for the increased width, and then save the view.

 

It almost works now, but it seems the first object passed is a blank string.  So we add the following method to String:

 

In String:

asSimpleCurrency

        "Return the receiver as a number formatted as a simple currency."

 

        ^self asNumber asSimpleCurrency

 

 

Editable Combo Box

 

The final part of this tutorial deals with the editable combo box we saw in Part 5.  We now add extensions that will select an item from the list that partially matches what the user types in.  See Part 5 for more details on this requirement as well as a reference to Microsoft's online MSDN library (http://msdn.microsoft.com/library/default.asp) that documents Combo Boxes and other Windows controls.

 

The first thing is to capture the keystroke that the user enters.  It turns out this is not easy.  Characters that the user enters in a combo box are eaten up by the Dolphin system -- you can read about it in the newsgroups or DSDN.  In other words, ComboBox does not generate keyPressed:, keyReleased:, or keyTyped: events.  I spent some time trying, but couldn't get through the 'pre-translations' and such.  Fortunately, you can detect when the edit portion of the control has changed.  To do this, you need to catch at least one of the notification messages that Windows sends to the combo box but which Dolphin currently ignores.

 

The table below lists the notification messages that Windows sends for combo boxes.  These messages are sent to the parent window via a Windows WM_COMMAND message that carries with it the notification message value.  We are interested in CBN_EDITCHANGE, which is sent to the parent window after the user has changed the contents of the edit control portion of the combo box.

 

Combo Box Notification Message

Value

CBN_CLOSEUP

 8

CBN_DBLCLK

 2

CBN_DROPDOWN

 7

CBN_EDITCHANGE

 5

CBN_EDITUPDATE

 6

CBN_ERRSPACE

 -1

CBN_KILLFOCUS

 4

CBN_SELCHANGE

 1

CBN_SELENDCANCEL

 10

CBN_SELENDOK

 9

CBN_SETFOCUS

 3

 

Here is a portion of ComboBox class>>initialize:

 

        CbnMap :=  IdentityDictionary new

                         at: -1 put: #cbnErrSpace;

                         at: 1  put: #cbnSelChange;

                         at: 2 put: #cbnDblClk;

                         at: 3  put: #cbnSetFocus;

                         at: 4  put: #cbnKillFocus;

 

It's clear that some of the combo box notification messages are 'registered' in Dolphin by listing them in this 'Combo Box Notification Map', an IdentityDictionary.  Each dictionary entry corresponds to a row in the table above.  Take a look at #command:id: in CombBox and you'll see part of the mechanism at work, in particular, the following line:

 

        self perform: (CbnMap at: anInteger ifAbsent: [^nil "accept default processing"]).

 

To include the notification for cbnEditChange we modify CbnMap as follows:

 

In ComboBox, class side:

initialize

        "Private - Initialise the receiver's class variables.

                 ComboBox initialize

        "

 

        CbnMap :=  IdentityDictionary new

                         at: -1 put: #cbnErrSpace;

                         at: 1  put: #cbnSelChange;

                         at: 2 put: #cbnDblClk;

                         at: 3  put: #cbnSetFocus;

                         at: 4  put: #cbnKillFocus;

                         at: 5 put: #cbnEditChange;

                         shrink;

                         yourself.

 

        Modes := #(simple dropDown dropDownList)

 

Do not forget to evaluate 'ComboBox initialize' after you make the change to the class method.  That's why the line is there in the comment -- to remind you and make it easier for you.  Otherwise, the new entry will not be added to the dictionary.

 

The next thing is to add method #cbnSelChange to ComboBox.  As for what the method should do, we start simple and have it just trigger an event -- #editChange.

 

In ComboBox:

cbnEditChange

        "Private - A CBN_EDITCHANGE has been received by our parent window."

 

        ^self presenter trigger: #editChange

 

Note that we direct the trigger in #cbnEditChange through the view's presenter, since it seems that most observers register through their presenters.  We then add #editChange to the view's list of published events and add an observer in the presenter.

 

In ComboBox, class side:

publishedEventsOfInstances

        "Answer a Set of Symbols that describe the published events triggered

        by instances of the receiver."

 

        ^super publishedEventsOfInstances

                 add: #editChange;

                 yourself

 

In PersonalAccountTransactionDialog:

createSchematicWiring

        "Create the trigger wiring for the receiver"

       

        super createSchematicWiring.

        categoryPresenter when: #focusLost send: #onCategoryFocusLost to: self.

        categoryPresenter when: #editChange send: #onEditChange to: self

 

This seems a bit weird -- inelegant -- we publish the event in the view but install the observer through the presenter.  But it does seem in accord with the rest of the system.  For example, PersonalMoneyShell contains accountsPresenter, which is a ListPresenter.  It then installs an observer on accountsPresenter, asking for a callback when #actionPerformed is triggered.  If you look at the published events for ListPresenter, you'll see it says there are none.  So where does this knowledge of #actionPerformed come from?  It comes from the view.  If you look at the View -- or even just the views that a ListPresenter would couple with, e.g., ListBox and ListView -- you'll see that #actionPerformed is a published event.  But if you try to install an observer on the view, i.e., 'accountsPresenter view when: #actionPerformed send: #editAccount to: self' (in PersonalMoneyShell>>createSchematicWiring), the event is not picked up.  This is because the view, in View>>#onActionPerformed, routes the event to the presenter.  The bottom line is that you need to peek around a little to see exactly what events might come through a particular type of view or presenter.

 

We move on to do something when #editChange is triggered, expecting to handle the event in PersonalAccountTransactionDialog>>#onEditChange.  The requirement is that as a user types in the edit portion of the combo box control, the listbox portion will be searched for a match to what has been entered so far.  If there's a match then select that item, a category, and put the full name of the category in the edit box.  In this case, also select and highlight (in the edit box) the portion of the full name that the user did not type, so that if this item is not the one the user wants, further typing will delete the portion of the edit box that was just added.  This makes it easier for the user to type one or more characters to find the desired category or continue typing to produce a new category, one that is not in the list at all.

 

Most -- though I don't think all -- of this can be done with existing Smalltalk methods.  Fortunately, there are two Windows messages -- CB_SELECTSTRING and CB_SETEDITSEL -- that can simplify things quite a bit.  The first message essentially fulfills the first part of our requirements -- it searches the listbox for a match against the edit box text.  If found, it selects that item in the list, puts the item in the edit box and selects all of the item in the edit box.  If not found, it clears the editbox (which is something we don't want, but we can get around it).  The second message lets us select a portion of the text in the edit box.  The table below lists all the Windows messages that combo boxes respond to.

 

Combo Box Message

Value

CB_ADDSTRING

 &H143

CB_DELETESTRING

 &H144

CB_DIR

 &H145

CB_ERR

 (-1)

CB_ERRSPACE

 (-2)

CB_FINDSTRING

 &H14C

CB_FINDSTRINGEXACT

 &H158

CB_GETCOUNT

 &H146

CB_GETCURSEL

 &H147

CB_GETDROPPEDCONTROLRECT

 &H152

CB_GETDROPPEDSTATE

 &H157

CB_GETEDITSEL

 &H140

CB_GETEXTENDEDUI

 &H156

CB_GETITEMDATA

 &H150

CB_GETITEMHEIGHT

 &H154

CB_GETLBTEXT

 &H148

CB_GETLBTEXTLEN

 &H149

CB_GETLOCALE

 &H15A

CB_INSERTSTRING

 &H14A

CB_LIMITTEXT

 &H141

CB_MSGMAX

 &H15B

CB_OKAY

 0

CB_RESETCONTENT

 &H14B

CB_SELECTSTRING

 &H14D

CB_SETCURSEL

 &H14E

CB_SETEDITSEL

 &H142

CB_SETEXTENDEDUI

 &H155

CB_SETITEMDATA

 &H151

CB_SETITEMHEIGHT

 &H153

CB_SETLOCALE

 &H159

CB_SHOWDROPDOWN

 &H14F

 

Class Win32Structure contains a pool dictionary -- Win32Constants -- that contains a list of associations, including most of the ones above.  You can inspect 'Win32Constants' or you can programmatically access the associations in it, e.g., evaluate the following to select a portion and output a sorted list in the Transcript (the output is shown below):

 

((Win32Constants keys select: [:key | 'CB_*' match: key]) asSortedCollection)

        do: [:key | Transcript nextPut: key; nextPut: ' = '; nextPut: (Win32Constants at: key) displayString; cr]

 

CB_ADDSTRING = 323

CB_DELETESTRING = 324

CB_FINDSTRING = 332

CB_GETCOUNT = 326

CB_GETCURSEL = 327

CB_GETDROPPEDCONTROLRECT = 338

CB_GETDROPPEDSTATE = 343

CB_GETITEMDATA = 336

CB_GETITEMHEIGHT = 340

CB_GETLBTEXT = 328

CB_GETLBTEXTLEN = 329

CB_INITSTORAGE = 353

CB_INSERTSTRING = 330

CB_RESETCONTENT = 331

CB_SELECTSTRING = 333

CB_SETCURSEL = 334

CB_SETITEMDATA = 337

CB_SETITEMHEIGHT = 339

CB_SHOWDROPDOWN = 335

 

All of these Windows messages can be sent to a Windows Combo Box control using the Windows SendMessage function.  Many of these messages are already in the system, wrapped in methods.  For example, CB_GETCOUNT is returned by #getCountMessage in CombBox.  #getCountMessage is called by #count in BasicListAbstract, the parent of CombBox (and ListBox).

 

We see that CB_SELECTSTRING is already in Win32Constants, though CB_SETEDITSEL is not.  Looking at how some other methods are implemented in ComboBox, BasicListAbstract and View, I came up with the following:

 

In ComboBox:

selectStringMessage

        "Private - Answer the Win32 message number to be used for attempting a search and select

        item of the receiver."

       

        ^CB_SELECTSTRING

 

In BasicListAbstract:

basicSearchFrom: anInteger forString: aString

        "Private -  Searches for aString in the listbox control, starting at 1 based index, anInteger.

        If anInteger is zero then the entire list is searched.  Answers the item number or zero."

 

        ^(self

                 sendMessage: self selectStringMessage

                 wParam: anInteger-1

                 lpParam: aString) + 1

 

searchFrom: anInteger for: aString

        "Searches for aString in the listbox control, starting at 1 based index, anInteger.

        If anInteger is zero then the entire list is searched.  Answers the item number or zero."

 

        ^self basicSearchFrom: anInteger forString: aString

 

searchFor: aString

        "Searches for aString in the listbox control, starting at the top.  Answers the item number or zero."

 

        ^self basicSearchFrom: 0 forString: aString

 

The first two methods are private.  #selectStringMessage returns the Windows message number.  It's used in the next new method, #basicSearchFrom:forString: which sends #sendMessage:wParam:lpParam to itself, passing the message number and a string.  It also does the translation from 1-based Dolphin to 0-based Windows, using wParam-1.  #sendMessage:wParam:lpParam is a wrapper around the Windows SendMessage function.  Windows does the rest, matching for an item in the list, returning the index of the item if found, otherwise -1 (and then translated back to 1-based indexing).  wParam specifies the item to start the search from, though the search will roll over from the top of the list if it gets to the bottom without finding anything.  lpParam contains a pointer to a string.  The second two methods present the public interface.  #searchFrom:for: says which item to start the search from and what to search for.  We also add a simpler method, #searchFor:, which passes 0 as wParam, meaning it always starts from the top of the list.

 

The next piece is to implement CB_SETEDITSEL.  We deduce that selecting a portion of text in the edit portion of the combo box should be similar to selecting a portion of text in an edit box, so take a look at TextEdit.  You'll find #basicSelectionStart:end: which sends #sendMessage to itself, along with EM_SETSEL, the Windows message for setting the selection in an edit box.  The first thing to do is add CB_SETEDITSEL to Win32Constants, by evaluating the following:

 

        Win32Constants at: 'CB_SETEDITSEL' put:  16r142

 

The remaining methods are similar to those above.

 

In ComboBox:

basicSelectionStart: start end: end

        "Private - Sets the selected range of text to the range defined by start and end

        (1 based, end-inclusive)."

 

        self sendMessage: CB_SETEDITSEL

                 wParam: 0

                 lpParam: (DWORD new lowSWord: start-1; highSWord: end) asInteger

 

selectionStart: start end: end

        "Sets the selected range of text to the range defined by the parameters start

        and end (1 based, start and end inclusive)."

 

        ^self basicSelectionStart: start end: end

 

selectionStart: start

        "Sets the selected range of text from start to the end of the text."

 

        ^self basicSelectionStart: start end: -1

 

Instead of a method returning CB_SETEDITSEL, we put it directly in #basicSelectionStart:end:, which also does the one to zero-based translating.  wParam for this Windows message always contains a zero.  lpParam is a bit trickier, it needs the starting position in the low word of a DWORD, and the end position in the high word.  Both the low and high words can contain -1, so we use signed words (Sword).

 

For the public interface, #selectionStart:end: takes two parameters, the first specifying the starting position of the selected text in the edit box, the second parameter specifying the last character.  #selectionStart is a simplied version that assumes end is the end of the text in the edit box.  It then calls basicSelectionStart:end: which sends #sendMessage:wParam:lpParam to itself.

 

The final thing, then, is to implement #onEdit Change, as follows:

 

In PersonalAccountTransactionDialog:

onEditChange

        "Edit portion of combo box has changed.  See if there's a matching selection in the list."

 

        | str |

        str := categoryPresenter view text.

        (categoryPresenter view searchFor: str) <= 0

                 ifTrue: [categoryPresenter view text: str].

        categoryPresenter view selectionStart: (str size + 1)

 

First we hold on to the current contents of the edit box, in str.  Then we search for a match.  If found it's put in the edit box, if not, it returns 0 and the edit box is cleared.  In this case we restore the contents of the edit box to its former value, through str.  Finally we select the portion of the text box that was just added when the item was found in the listbox and copied to the edit box.  If it wasn't found, then that line will position the cursor at the end of what the user typed in.

 

It works pretty well.  There is still a problem related to not being able to capture the backspace or delete key.  If the edit box contains a value that matches something in the list and the user deletes or backspaces on the portion of text that is highlighted, the text that remains will match up once again (in #onEditChange) with a value from the list and the same portion will be highlighted.  It may only be a problem if the user wants to create a new category that happens to be the same, but shorter, than an existing category.  For example, let's say there's an entry 'Clothing' in the list and the user types 'c' in the edit box.  The 'c' will match up with 'Clothing' and 'Clothing' will be copied to the edit box with 'lothing' selected.  Now let's say the user wants a category named 'Cloth' and so backspaces from the end of 'Clothing' or selects 'ing' and clicks on Delete.  Unfortunately, #onEditChange will go through its matching process with whatever is left and will choose 'Clothing' each time, effectively undoing the backspace or delete.

 

To prevent this, we need to keep track of some information from before the new keystroke.  There is a Windows notification message CBN_EDITUPDATE that we could trap just as we did CBN_EDITCHANGE.  This message is supposed to trigger when there is a change to the edit box but before the display is altered.  This way we can take a look at it before and after the keystroke, then figure out what to do.  I implemented onEditUpdate but it appears that the display is already altered by the time we get to the event handler.  (I added the same line in both #onEditChange and #onEditUpdate that wrote the text box's contents to the Transcript and they were the same.)  In other words, this approach doesn't help.

 

Another way to track the before and after is to add an instance variable (e.g., editText) to the main presenter and then set its value at the end of #onEditChange.  The next time through #onEditChange we have the old value to check against the new.  Not very fancy but it works for this problem.  If the text now showing is smaller (i.e., fewer characters) than the previous text, this indicates the user pressed backspace or delete, in which case we do not want to try to match the edit text with a value in the list.  Here then, are the new class definition and the changed method:

 

Dialog subclass: #PersonalAccountTransactionDialog

        instanceVariableNames: 'datePresenter amountPresenter descriptionPresenter isDebitPresenter categoryPresenter editText'

        classVariableNames: ''

        poolDictionaries: ''

        classInstanceVariableNames: ''

 

onEditChange

        "Edit portion of combo box has changed.  See if there's a matching selection in the list."

 

        | str |

        str := categoryPresenter view text.

        editText isNil ifTrue: [editText := String new].

        str size > editText size ifTrue: [

                 (categoryPresenter view searchFor: str) <= 0

                         ifTrue: [categoryPresenter view text: str].

                 categoryPresenter view selectionStart: (str size + 1)].

        editText := str.

 

Note that editText is lazily initialized the first time through.  (There should probably be separate accessor methods for editText, probably private, and these used, rather than using the instance variable directly.)

 

 

Next Steps

 

If you've found this exercise helpful, let me know -- I welcome comments, criticism and feedback.

 

Written by Louis Sumberg

Last updated December 2000

 

 

Back to Part 5