Up  Next

Extending the PersonalMoney Application

 

Part 3

 

 

Objectives and Overview

 

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:

 

 

 

Alleviate the tedium (with a helper method)

 

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]

 

 

Compiler evaluate

 

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).

 

 

Add another listview

 

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.

 

 

 

Sorting revisited

 

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

 

 

Binary Filing

 

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.

 

 

Dates and formating changes

 

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'])

 

 

LayoutManager

 

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:

 

 

 

Next Steps

 

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.

 

Written by Louis Sumberg

Last updated December 2000

 

 

Back to Part 2              Onwards to Part 4