Extending the PersonalMoney Application
Part 6
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.
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.
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:
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
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
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.)
If you've
found this exercise helpful, let me know -- I welcome comments,
criticism and feedback.