Extending the PersonalMoney Application
Part 3
In Parts 1 and 2 we added an account type to the PersonalMoney model, modified the display to include a combox box and a listview, made the application a bit more robust, and subclassed ListView. We also touched on some areas that most people, especially newcomers, seem to run into, such as IdentitySearchPolicy, orphaned instances (zombies), and sorting.
In this, Part 3, we continue in a similar vein, adding more zip to the display, the same goals in mind -- to make the application more robust as a means towards learning a little more about Object Arts' implementation of Dolphin Smalltalk. Specifically, we will:
Classes and other areas to look at include:
In Part 2, we
saw that we could assign a sortBlock to each column in the VC. We also found a way to apply a single
sortBlock to all of the columns. It's
nice when it saves on typing -- if you don't need to overspecify, then
don't. On the other hand, there's the
concept of "type now to save for the future" -- we'll go with the
other hand. In setting up the listview
for PersonalMoney I couldn't help but notice it would be nice if the listview
could set itself up -- given some minimal information, that is. And that brings up the question "What
repeating things do you do with listview in VC?"
The key
things here are the model and its aspects that you're interested in. Given these, you can have a method do the
rest. As for the model's aspects, since
the model should know its own aspects, let's have the model prompt with a list
of choices. The key item then is the
model -- we want to setup some columns on a model, something like this
pseudo-code (and remember, this method will be in AcDcListView):
setupColumnsOn: aModel
"Prompt with a list of
aModel's aspects, then add columns for each aspect.
Set the text and getContentsBlock of
each column."
aspects := choose from aModel's aspects. "instance
variables with get and set accessors"
self add/delete columns to match the number of aspects.
for each aspect/column do: [
column text: aspect.
column getContentsBlock: [:model | model aspect]]
The starting
place is to prompt the user with a list of aModel's aspects and have him choose
from that list. Class ChoicePrompter
provides several prompting methods and class Behavior provides the means for a
class to know its own characteristics (self-reflection). Look at ChoicePrompter and you'll see a
number of methods on the class side.
Focus on the methods that allow for multiple selection and browse the
references to these to see how they are used already in the image. Fortunately, you'll find #generateAccessors
which contains much of the functionality we're looking for.
In
ClassBrowserShell:
generateAccessors
"Prompt to generate
compiled 'get' and 'set' accessor methods for each of the immediate
instance variables of the current class
that are not currently endowed with both."
| class varNames |
class := self actualClass.
varNames := class instVarNames
reject: [:each |
(class includesSelector:
each asSymbol)
and: [class includesSelector: (each,':') asSymbol]].
varNames := ChoicePrompter on:
varNames
multipleChoices: varNames
caption:
'Choose instance variables'.
varNames notNil ifTrue: [
varNames do: [:each | class
generateAccessorMethods: each]]
With a few
changes, we can lift almost all of this and use it for the first part of our
method. The big change is that we want
to look at all instance variables, inherited and local, in the specified
model's class, not just those instance variables declared locally. Look at Behavior and its subclass
ClassDescription -- you'll find two of the methods used above (#instVarNames
and #includesSelector:) and many more that help to programatically examine the
state and pieces of a class. For our
method, we want to acess all instance variables, not just those declared
locally, so we'll use #allInstVarNames.
Likewise, we want to look at all accessors of the class, not just the
local ones, so we'll use #canUnderstand:.
Here then, is the first part of the new method:
setupColumnsOn: aModel
| class aspects |
class := aModel class.
aspects := class allInstVarNames
select: [:ivar |
(class canUnderstand:
ivar asSymbol) and:
[class canUnderstand: (ivar,':') asSymbol]].
aspects := ChoicePrompter on:
aspects multipleChoices:
aspects
caption:
'Choose model aspects'.
aspects size > 0 ifTrue: [
[ "add columns and
set headers, getContentsBlock, and sortBlock" ]
You could,
in fact, build this up and test it, as usual, in the workspace. For instance, inspect or display the
following:
| aModel class aspects |
aModel := PersonalAccount new.
class := aModel class.
aspects := class allInstVarNames select: [:ivar |
(class canUnderstand: ivar asSymbol) and:
[class canUnderstand: (ivar,':') asSymbol]].
aspects := ChoicePrompter on: aspects
multipleChoices: aspects
caption: 'Choose model
aspects'.
The
remaining part of the new method will first check to ensure that the number of
columns match the number of chosen aspects, which means adding or removing
columns from the existing listview.
Then it will loop over the list of aspects. For each column, it will set the header for the column to the
aspect name (capitalizing the first letter), it will restore the sortBlock to
SortedCollection (just for neatness, this isn't necessary since the sortBlock
will be changed anyway each time #sortOn: is called), and it will set the
getContentsBlock to return the model's aspect.
Here's the complete method:
In
AcDcListView:
setupColumnsOn: aModel
"Prompt with a list of
aModel's aspects, then add columns for each aspect.
Set the text and getContentsBlock of each
column."
| class aspects |
class := aModel class.
aspects := class allInstVarNames
select: [:ivar |
(class includesSelector:
ivar asSymbol)
and: [class includesSelector: (ivar,':') asSymbol]].
aspects := ChoicePrompter on:
aspects multipleChoices:
aspects
caption:
'Choose model aspects'.
aspects size > 0 ifTrue: [
[aspects size >
self allColumns size]
whileTrue: [self addColumn].
[aspects size <
self allColumns size]
whileTrue: [self removeColumnAt: self
columns size].
(1 to: aspects size) do: [:index | | aspect |
aspect := aspects
at: index.
(self allColumns
at: index)
text: (aspect leftString: 1) asUppercase,
(aspect rightString: aspect size - 1);
sortBlock: SortedCollection;
getContentsBlock: (Compiler evaluate:
'[:model
| model ',
aspect, ']' logged: false)].
self primaryColumn text:
(self allColumns at:
1) text]
The second half of the #setupColumnsOn: method is mostly straightforward except for the last part, "set the getContentsBlock to return the model's aspect". Note the next-to-last line (that contains "Compiler evaluate:"). When I first looked at setting the getContentsBlock I thought, too bad I can't just say "self getContentsBlock: [:model | model aspect]" No no no, it's not going to be that easy, aspect has to be passed in as a parameter or used somehow as a variable. So then I constructed "self getContentsBlock: ('[:model | model ', aspect, ']) asBlock", trying to construct a block from a string and a variable, but darnit, there's no #asBlock message in the system, let alone in String. After poring over many posts in DSDN I tried Compiler>>evaluate:logged: which seems to have the same effect as my nonexistent String>>asBlock.
The problem with blocks is that they can carry quite a bit of baggage around with them. Be aware that some nasty things can happen if you're not careful. Depending on how and where you create a block, it retains a reference to its local context, a reference which may or may not automatically go away. This can lead to zombies in your shorts again, like I said, a nasty prospect. I wish I understood it better at this point. For example, I tried changing the sort blocks in AcDcListView from class variables to instance variables. The rest of the code was the same. The application would run fine, but if I sorted on a column, then I'd find myself with an orphaned AcDcListView instance. Interesting too was that #destroy wouldn't get rid of it, but #oneWayBecome: would. About all I'll add at this point is for you to take a look at the posts in DSDN (search for Blocks and Compiler evaluate).
Moving right along, we have this helper method and now we'll also override #initialize in AcDcListView to initialize lastColSelected to the first column, turn grid lines on, and remove the icons from the primary column (column 1). None of these are absolutely necessary, just conveniences.
In
AcDcListView:
initialize
"Private - initialize the
receiver."
super initialize.
lastColSelected := self
primaryColumn.
self hasGridLines: true.
self imageManager: nil
It's now
time to test this. We have a prime
candidate in PersonalAccountShell, whose default view has a listbox. Take a look at it now. Bring up the application ("PersonalMoneyShell
show"), open the last file created (PersonalMoney2.pm), and add a few
transactions. My view for one of the
accounts looks like this:

Return to
the main application window, save the file (PersonalMoney2.pm), and exit the
application.
Now we'll
change the listbox to a listview. Bring
up VC on PersonalAccountShell and mutate the transactions listbox to an
AcDcListView. You'll notice where the
listbox was, there's a listview now, with grid lines turned on and no icons
showing in column 1. With transactions
listview still selected, evaluate the following in the PAI (Published Aspect
Inspector, in the VC) workspace on the right:
self setupColumnsOn:
PersonalAccountTransaction new.
A prompter
will appear with all four instance variables selected. Click OK.
The columns are created, headers are set to default values, and if you
look at the individual columns, you'll see that the getContentsBlock for each
column has been set.
Version
4 update: As in
Part 1, the listbox from Dolphin 2.1 brings with it a getTextBlock that may
cause problems. Press F5 to run the
view before you save it. If you get a
walkback, then change the getTextBlock for the AcDcListView to
'BasicListAbstract'.
Now save
the view, run the application again, read in that last file (PersonalMoney2.pm)
and go to the Personal Account Details view.
It may look something like this.

That was
quick and the defaults look pretty good.
First though, verify that you can sort on each column. You'll find that the sort fails on IsDebit,
with a walkback appearing, saying that True or False does not understand (DNU)
"<=". This is very similar
to what we saw in Part 2, with nil.
IsDebit is a boolean and the listview will display true or false for its
values since the default getTextBlock just retrieves the object's
displayString. But when it comes to the
sort, it does use the actual boolean values, true and false, and these do not
respond to #<=. We can change the
sortBlock as we did with nil, but then the sortBlock will start getting rather
unwieldy, so this time we change the getContentsBlock. Bring up VC again on PersonalAccountShell
and change the getContentsBlock for the last column to:
[:model | model isDebit displayString]
The sort should work fine now, on all columns. By the way, in regards to getContentsBlock and getTextBlock, Andy Bower has a nice explanation of how they work and when to use them -- see DSDN, 25 Feb 1998.
Actually,
although the sort works on all columns, there's still a bit of weirdness
there. Resize one of the column widths
and then click on the column header -- the column resizes back to its original
width. Definitely rude behavior. How do you trace this problem? Well, since it doesn't happen with regular
ListViews, it must be something in AcDcListView. I put a self halt in AcDcListView>>sortOnColumn: and sure
enough, that led right to the cause.
The reason it resizes the column width is because when you click on the
column header, it calls #sortOnColumn: which replaces the sortBlock in the
ListViewColumn. This causes the column
to update itself (in ListViewColumn>>sortBlock:) which results in an
#onListChanged: message being generated and brings us to ListView>>updateColumnAtIndex:
which contains the following code:
self
lvmSetColumn: (LVCOLUMN fromColumn: column in: self)
at: columnIndex -
1;
updateAll
The call to
LVCOLUMN>>fromColumn:in: returns an LVCOLUMN with the default width. (LVCOLUMN represents the Win32 structure for
a ListViewColumn and is used in actual calls to the Windows DLL.) Here's how it's initialized in this case:
In
LVCOLUMN, class side:
fromColumn: aListViewColumn in: aListView
"Answer an LVCOLUMN generated from aListViewColumn
using the attributes of aListView to
generate the width of the column if necessary."
^self new
text: aListViewColumn text;
width: aListViewColumn basicWidth;
alignment: aListViewColumn alignment;
yourself
#basicWidth
returns the default width of the column.
Checking method references shows that #fromColumn:in: is also called
from a private method in ListView, i.e., #basicAddColumn:. Now I can understand why it would use
basicWidth when adding a new column, but why not use the actual width of the
ListViewColumn when it's available, as it should be for an existing
column. And that brings us to the new
method:
In
LVCOLUMN, class side:
fromColumn: aListViewColumn in: aListView
"Answer an LVCOLUMN
generated from aListViewColumn."
^self new
text: aListViewColumn text;
width: (aListViewColumn width < 1000
ifTrue: [aListViewColumn width]
ifFalse:
[aListViewColumn basicWidth]);
alignment: aListViewColumn alignment;
yourself
The reason
for the "width < 1000" test is that a new column seems to have an
enormous width, i.e., 163187608, so this tests to see if we're looking at a new
or an existing column. I also changed
the comment since the parameter aListView is, and wasn't, used. With the changed method in place, all seems
well and hopefully, we won't see any sorting problems for awhile.
Version
4 update: A new column in version 4 seems to be
initialized to zero, so we change the test to check for zero.
In
LVCOLUMN, class side:
fromColumn: aListViewColumn in: aListView
"Answer an LVCOLUMN
generated from aListViewColumn."
^self new
text: aListViewColumn text;
width: (aListViewColumn width > 0
ifTrue: [aListViewColumn width]
ifFalse: [aListViewColumn basicWidth]);
alignment: aListViewColumn alignment;
yourself
After testing the sort, save the file under a new name (PersonalMoney3.pm) and then read the file back again. This time you won't even be able to load the file -- another walkback appears, saying "failed to create window". This one I tracked down to somewhere in the binary filing system but couldn't figure out exactly where till I saw Bill Schwab's writeup on this exact problem at the Dolphin Wiki. The listmodels we use contain OrderedCollections, but when you click on a column, they are replaced by SortedCollections. Apparently, this confuses the binary filer when it tries to read the collections back in. The answer then is to turn them back to OrderedCollections just before they're filed out. STBFiler has a private method, #basicNextPut:, that sends #stbSaveOn: to an object, telling the object to save itself on the STBFiler stream. The default implementation of #stbSaveOn: is defined in Object and it essentially writes out all index variables and their contents. It also gives us the opportunity to include some custom processing by overriding it in our model classes. That's exactly what we do, overriding #stbSaveOn:, restoring each listmodel's list to an OrderedCollection, then calling the parent method to file it out.
In PersonalMoney:
stbSaveOn: anSTBOutFiler
"Output the receiver to
anSTBOutFiler. Restore any sorted listmodels to anOrderedCollection."
self accounts list: self accounts list asOrderedCollection.
self accountTypes list: self accountTypes list asOrderedCollection.
super stbSaveOn: anSTBOutFiler
In
PersonalAccount:
stbSaveOn: anSTBOutFiler
"Output the receiver to
anSTBOutFiler. Restore any sorted listmodels to anOrderedCollection."
self transactions list: self transactions list asOrderedCollection.
super stbSaveOn: anSTBOutFiler
Note that we don't need to restore the accountTypes list since it is never sorted, but it might be at a later date, so we include it now. You can try to save and load again now. Read in the data from PersonalMoney2.pm and sort on at least one column. Save the data to PersonalMoney3.pm and read it back in. It should load with no problem this time.
Note: The builtin serializing capability in DocumentShell works like
this. An object can be represented in
text or in binary form, as determined by its #isText method, which defaults to
false, meaning binary. Method
#fileSave, which is what the menu item File/Save calls, creates a FileStream on
the filename which is prompted for or is where the file was last read
from. In the case of binary data, which
is what we have here, the presenter (DocumentShell) then tells its
"DocumentData" (via #getDocumentData), the default which is its model,
to store itself (#binaryStoreOn:) on the file stream. An instance of STBFiler is then created on the file stream and
told to store the data (model). It does
this in #basicNextPut: which looks at things like is this a Global, proxy,
SmallInteger, Character, etc, and handles each of these differently. In a case like our models, which are not
Globals, proxies, etc, the object is sent #stbSaveOn:, telling it to save
itself on the STBFiler file stream.
#stbSaveOn: is implemented in many classes, where it usually does a
piece of special processing and then calls its parent #stbSaveOn:. The special processing might be suppressing
the saving of events to file (Model and ListModel do this), or saving an
OrderedCollection as a proxy. Things
wind their way to a private method in STBFiler, #writeObject:as:withPrefix:
which saves the object, in a number of possible ways, in our case, saving all
named and instance variables.
There's
definitely room for improvement in the new listview. Something I notice is that the columns need some formating
changes. The Date should really be in a
short format. Description should be
wider and the other columns narrower.
Amount should be right-justified.
Finally, Amount and IsDebit could really be combined into a single
column which shows actualAmount, or for a more traditional ledger- or
checkbook-like display, they can be replaced by Debits and Credits columns.
Let's start
with Date. The default format for Date
is really determined by your Windows settings.
You can see and change these settings through Control Panel/Regional
Settings. I'm in the USA, so mine look
like this and may be different from yours:

Within Dolphin Smalltalk, you can change the global default format to display a long or short date by evaluating Date defaultLongPicture: aBoolean. You can see what the masks are for the long and short formats by displaying Date defaultLongFormat and Date defaultShortFormt. For example, this is what I get:
|
Expression |
displays |
|
Date defaultLongFormat |
'dddd, MMMM dd, yyyy' |
|
Date defaultShortFormat |
'M/d/yy' |
|
Date defaultLongPicture: true. Date today |
Friday, October
06, 2000 |
|
Date defaultLongPicture: false. Date today |
10/6/00 |
Another way to format dates is to use class DateToText. For example,
(DateToText new
format: 'MMM-dd-yy') convertFromLeftToRight: Date
today
displays 'Oct-06-00'.
To implement the formating changes to the PersonalAccountShell
listview, bring up VC, select the transactions listview and make the following
changes to aspects in each column:
Notice that
for Date, we leave getContentsBlock as is and change the getTextBlock. This is because we want the values to be
dates so that they will be sorted properly.
Since Date is a Magnitude, it does understand "<=". So getContentsBlock remains as [:model |
model date]. If we had changed
getContentsBlock to [:model | (DateToText new format: 'MM-dd-yy')
convertFromLeftToRight: model date], then the values in the column would be
strings and the sort wouldn't work correctly, e.g., "10-10-99" would
come after "10-10-00", not before.
Also, we change column 2 last so that the autoresizing on the column is
done after we've adjusted the widths of the others. The changed view should look something like this:

It looks
pretty good. Ah, notice the
caption. I added the following method
(almost forgot to mention it):
PersonalAccountShell
onViewOpened
"Set the caption for the
window."
super onViewOpened.
self view caption: 'Personal Account Details - ',
(self model name notEmpty
ifTrue: [self model name] ifFalse: ['unnamed'])
The final
item for now is to use a LayoutManager on PersonalAccountShell's view. This will allow the controls and subviews to
resize automatically when the view resizes.
If you're not familiar with the various layout managers, then this is a
good time to read the comments on LayoutManager and its subclasses. You can get a good idea on how some of these
are used in the system itself by bringing up the VC on some views, e.g., ClassBrowserShell,
and then looking at the layoutManager and arrangement aspects of the various
containers and controls.
For
example, the class browser uses a BorderedLayout, where the toolbar is always
at the top (#north), the status bar is always at the bottom (#south) and the
main view, a container holding the various subviews, occupies the central part
of the window (#center). This central
view, in turn, uses a proportional layout, where a container holding the three
subviews on top (classes, categories and methods) keeps the same height and
width as the workspace on the bottom.
The container holding the three views on top also uses a proportional
layout, with each subview keeping the same width as each other when the view is
resized. Thus, when you resize the
class browser, you'll always see the toolbar on top, the status bar on the
bottom, and the central view with the top three views of equal width to each
other and with the same height as the workspace on the bottom. Notice too that the containers with
proportional layouts also contain splitters.
This is very common and it allows the subviews to maintain their
proportions relative to the containing view when the view is resized. Thus, if you use the splitter to change the
width of any of the three top subviews, then resize the window, you'll find
that the three subviews maintain the same proportions relative to the window's
width when resized.
BorderedLayout,
while very powerful and simple, does have a few drawbacks, the main being that
if you have a lot of controls, you'll find you need to place some of them here
and there on a container view and then assign that container view an aspect of
#north, #center, etc. Furthermore, if
you want precise positioning, you'll need to specify a layout manager for each
container view and then assign arrangement aspects for each control within it,
which even then may not give you the exact positioning you want. For this reason, we're going to use a
FramingLayout for PersonalAccountShell's view which, by the way, is considered
the most complex layout manager. Aren't
you glad you're here *g*.
In a
nutshell, we're going to specify a FramingLayout for the main window and then
specify various arrangements for the controls on the bottom half. Those on the upper half we'll leave as is,
with absolute coordinates. The key
things we want to happen when the window is resized are:
Before
specifying the layout's aspects, we need to ensure that the main window itself
can be resized. Bring up VC on
PersonalAccountShell and change the following aspects for the main view:
What makes
a FramingLayout complex is that the arrangement aspect for each subview is not
a simple symbol (e.g., #north, as in a BorderedLayout) or value (e.g., 1, as in
a ProportionalLayout), but is an instance of FramingConstraints and contains
four pairs of values. Each pair
corresponds to the left, top, right or bottom positions of the subview. The first part of the pair specifies where
the particular edge (left, right, top or bottom) is to be located. This location may be specified in absolute
coordinates or relative to something else, for example, the parent view, the
previous view, or even the width or height of the parent view. The second part of the pair specifies an
offset which is typically an absolute value (in pixels) or may be a percentage
of the parent's height or width.
The default
values for a subview's FramingConstraints works like this: the top and left edges are specified to be
relative to the parent view and the offsets are absolute values. The right and bottom edges for the subview
are relative to the subview itself with the offsets again in absolute
terms. This essentially specifies a
fixed placement and size for the subview.
You can see this by bringing up VC on PersonalAccountShell, changing its
layoutManager aspect to FramingLayout, and then examining the arrangement
aspects for its various subviews (controls).
This should
all become clearer as we work through the changes for our view. To start with, we want the buttons to always
be near the bottom. This means the top
edge of each button will always be the same distance from the bottom of the
window, the window being the parent view of the buttons. So select one of the
buttons, select and expand its arrangement aspect, and change topFraming to
#fixedParentBottom and topOffset to -50.
You can test the change in VC by pressing F5 and then resizing the
window -- you'll find the button you just changed stays near the bottom at all
times. Go ahead and make the same
changes to the other buttons.
The next
change to the buttons is a bit more complex and involves relative percentages.
Right now, the buttons are all ordered in a row, left to right.
If the window is maximized, they are clustured way to the left, and
if the window is narrowed, the right button falls out of view. One way to fix this is to position each button relative to the window's
width and also to size the button's width relative to the window's width.
To implement this, change the leftFraming and rightFraming aspects
for each button's arrangement to #relativeParentWidth.
Then change the values for leftOffset and rightOffset (which are values
between 0 and 1) for the buttons as follows:
|
Button |
leftOffset |
rightOffset |
|
New |
0.05 |
0.20 |
|
Edit |
0.30 |
0.45 |
|
Delete |
0.55 |
0.70 |
|
Exit |
0.80 |
0.95 |
This will
order the buttons from left to right, always as a percentage of the window's width,
and the width of each button will be 15% of the the window's width. This seems to work well at most window
sizes. Again, try testing it (F5 in VC)
and see for yourself.
The next
thing is to place the "Balance" label and textbox. We want these to be right-justified and just
under the listview (or conversely, just above the buttons). Start with the textbox first. Similar to what we did with the buttons,
change topFraming to #fixedParentBottom and topOffset to -95. Then change rightFraming to #fixedParentRight
and rightOffset to -10. This will keep
the textbox just above the buttons at all times and 10 pixels away from the
right edge of the window. Finally, to
keep its width at 60, the same as the last column in the listview just above
it, change leftFraming to #fixedViewRight and leftOffset to -60. This last part says that the textbox's left
edge should always be 60 pixels to the left of its own right edge. I know this can be confusing at first, but
once it makes sense, it's easy ... and you'll like it.
To line up
the label just to the left of the textbox, change topFraming to
#fixedParentBottom and topOffset to -95, and change rightFraming to
#fixedParentRight and rightOffset to -70.
Also change its alignment aspect to #right.
The final
change is the listview. We want the
left and right sides to be inset just a bit from the window's edges, so leave
leftFraming at #fixedParentLeft but change leftOffset to 10, and change
rightFraming to #fixedParentRight with a rightOffset of -10. Then, to adjust its height, change
bottomFraming to #fixedParentBottom with a bottomOffset of -105. Again, try testing it and you'll see some
nice results. For example, this is what
I have:

As I've said before, there's always more that can be done -- enhancements as well as making the application more robust while demonstrating and learning Dolphin Smalltalk features. If you have any ideas or suggestions, particularly on how PersonalMoney could be enhanced, especially as a learning experience, by all means let me know. In the meantime, don't forget to destroy those zombies, save any new classes or methods to your package, save your package, your image and your workspace.
If you've
found this exercise helpful, let me know -- I welcome comments,
criticism and feedback.
Back to Part 2 Onwards to
Part 4