Up  Next

Extending the PersonalMoney Application

 

Part 4

 

 

Objectives and Overview

 

System-wide bi-directional sorting:  Back in Part 2, we subclassed ListView because AcDcListView needed an instance variable to keep track of the last column selected in order to know whether or not to switch the sort order when a column is clicked.  At the time I noted that we could subclass ListViewColumn also and implement a reverse sort block there, "but for now, one subclass is enough".  Around the same time there was some discussion in the Dolphin newsgroup of bi-directional sorting.  (Search DSDN for "ascending/descending".)  Jerry Bell had implemented bi-directional sorting in version 2.1 by making changes to ListViewColumn.  In fact, he had implemented his changes directly in ListViewColumn, rather than a subclass, and they worked fine, instantly providing all existing listviews with bi-directional sorting.  Blair McGlashan made a great suggestion, i.e., "wouldn't it be adequate to simply invert the result of a single sort block?  [:a :b | (sortBlock value: a value: b) not]."  He added, "It might even be possible not to need an additional inst. var. to hold the 'current' sort block since a test could be used to determine whether to invert the result."

 

In this exercise, we're going to back out many of the changes made in the past 2 parts, placing this bi-directional sorting functionality directly in ListViewColumn and ListView.  As usual, though the changes turn out to be conceptually simple, and don't even require much code, things are not a piece of cake.  We'll take a brief look at versioning and, depending on how cruel or masochistic I feel, we'll look at some of the problems that arise when making changes to the base system, as well as backing out changes.

 

 

Reversing a sort block

 

A starting point is the pseudo-code from Part 2, for the proposed #sortOnColumn:, which we implemented in AcDcListView, but which will now go in ListView:

 

sortOnColumn: aListViewColumn

        "Changes aListViewColumn's sortBlock to a descending or ascending sort

        depending on if aListViewColumn was the last column clicked or not.

        Sorts the receiver according to the sort block in aListViewColumn."

 

        (lastColumnSelected = aListViewColumn) ifTrue: [aListViewColumn reverseSortBlock].

        lastColSelected := aListViewColumn.

        super sortOnColumn: aListViewColumn

 

The pseudo-code in the first line is a bit simplified, but it's basically saying if the same column is clicked as before, reverse its sortblock.  Do this by sending the message #reverseSortBlock to the ListViewColumn.  Now how will the column implement this?  It already has a sort block.  Can we just tell the sortBlock to reverse itself?  Let's try a few things in the workspace.

 

The default sort block for a column is SortedCollection.  This works because SortedCollection class implements #value:value: and that's what's really called later on.  It seems that the reverse of SortedCollection would be something like "SortedCollection not", but it's apparent that that particular expression won't fly. 

 

For example, let's say we have a SortedCollection instance that contains a default sort block.

 

"display"

inst := SortedCollection with: 3 with: 5 with: 4.

inst sortBlock.

 

This shows a SortedCollection with 3 elements (3 4 5) and a sortBlock whose value is SortedCollection.  Now let's try to reverse the sort:

 

inst sortBlock: [:a :b | (inst sortBlock value: a value: b) not]

 

Unfortunately, this hangs the Dolphin system and you'll need to kill the task.  Fortunately, it doesn't ruin the image, but you will need to restart Dolphin.  Let's try again, using a temp variable this time:

 

        t := inst sortBlock.

        inst sortBlock: [:a :b | (t value: a value: b) not.]

 

This now shows a SortedCollection with the 3 elements in reverse order (5 4 3).  It seems to have worked.  Let's try to reverse it again:

 

        t := inst sortBlock.

        inst sortBlock: [:a :b | (t value: a value: b) not.]

 

Ack!!!  This hangs up Dolphin again.  It would appear that we can't use this approach to keep reversing the sort block.  That's the bad news.  The good news is that given a sort block, we can easily reverse it one time.  And that, as we will find, is good enough.  The existing sortBlock in ListViewColumn contains the "regular" sortBlock.  We've already seen that we can reverse this sortBlock once.  So what we need is a mechanism that says use either the regular sort block or use the reversed sort block.  For that we'll need to add an instance variable, let's say a Boolean, called isReverseSort.  We go back to the workspace and initialize some variables:

 

"initialize"

inst := SortedCollection with: 3 with: 5 with: 4.

sortBlock := inst sortBlock.

isSortReversed := false.

 

sortBlock contains the sortBlock of the collection, just as a ListViewColumn keeps an instance variable holding the sort block for the column.  isSortReversed serves to keep track of whether to use the sortBlock as is or to use a reversed version.  Try displaying the following lines a few times and you'll see the results switch back and forth between (3 4 5) and (5 4 3).

 

"display several times"

isSortReversed := isSortReversed not.

isSortReversed

         ifTrue: [inst sortBlock: [:a :b | (sortBlock value: a value: b) not]]

         ifFalse: [inst sortBlock: sortBlock].

 

Great!  The next thing then is to look at where this will go in ListViewColumn.  Again, back in Part 2, we saw that ListView>>sortOnColumn: "tells its presenter to sort itself using the selected column's sortBlock".  Here are #sortOnColumn:, as well as the key method it calls, #rowSortBlock:

 

In ListView:

sortOnColumn: aListViewColumn

        "Sorts the receiver according to the sort block in aListViewColumn"

        Cursor wait showWhile: [

                 self presenter beSorted: aListViewColumn rowSortBlock]

 

In ListViewColumn:

rowSortBlock

        "Private - Answer a two argument block that can be used to compare

        two rows based on this column"

 

        ^[:a :b |

                 self sortBlock

                         value: (self contentFromRow: a)

                         value: (self contentFromRow: b)]

 

The changes to rowSortBlock are fairly simple.  We test to see which sortBlock should be returned and then return it.  The blocks have double brackets because we are returning a block that is itself within a block (i.e., #ifTrue:ifFalse:).

 

In ListViewColumn:

rowSortBlock

        "Private - Answer a two argument block that can be used to compare

        two rows based on this column"

 

        self isSortReversed

                 ifTrue: [^[:a :b | (self sortBlock value: (self contentFromRow: a)

                         value: (self contentFromRow: b)) not]]

                 ifFalse: [^[:a :b | self sortBlock value: (self contentFromRow: a)

                         value: (self contentFromRow: b)]]

 

 

Changes to ListViewColumn

 

We're talking about making changes to the base system, so it's good to think a minute about what you expect will happen as you make the changes.  That is, will the system find itself in an inconsistent state and then burp, gasp and roll over?  After all, ListViews are used in the class browsers, for instance.  The changes so far seem fairly innocuous.  If we look at just ListViewColumn, we're talking about adding one instance variable (isSortReversed) and changing one existing method.  In addition, we need accessor methods for the new instance variable and we should initialize it.  Here then are the additions and changes:

 

In ListViewColumn:

Object subclass: #ListViewColumn

        instanceVariableNames: 'text width alignment getTextBlock getSortValueBlock getContentsBlock compareBlock parent getImageBlock isAutoResize getInfoTipBlock isSortReversed'

        classVariableNames: ''

        poolDictionaries: ''

 

isSortReversed

        "Answers a boolean indicating if a regular or reversed sort will be used on the receiver."

 

        isSortReversed isNil ifTrue: [isSortReversed := false].

        ^isSortReversed

 

isSortReversed: aBoolean

        "Set boolean variable to control if  the regular or reverse sort is used on the receiver."

 

        isSortReversed := aBoolean

 

rowSortBlock

        "Private - Answer a two argument block that can be used to compare

        two rows based on this column"

 

        self isSortReversed

                 ifTrue: [^[:a :b | (self sortBlock value: (self contentFromRow: a)

                         value: (self contentFromRow: b)) not]]

                 ifFalse: [^[:a :b | self sortBlock value: (self contentFromRow: a)

                         value: (self contentFromRow: b)]]

 

We could initialize isSortReversed to true or false in ListViewColumn>>initialize, but that wouldn't help existing instances in the image, so we use lazy initialization in #isSortReversed.  Now try clicking on the column headers in a class browser and see what happens.

 

The good news is that nothing blew up.  The bad news is that there's no reverse sort.  The reason for this is that isSortReversed is never changed to true.  A quick change to #rowSortBlock can fix this.

 

In ListViewColumn:

rowSortBlock

        "Private - Answer a two argument block that can be used to compare

        two rows based on this column"

 

        self isSortReversed: self isSortReversed not.

        self isSortReversed

                 ifTrue: [^[:a :b | (self sortBlock value: (self contentFromRow: a)

                         value: (self contentFromRow: b)) not]]

                 ifFalse: [^[:a :b | self sortBlock value: (self contentFromRow: a)

                         value: (self contentFromRow: b)]]

 

The new method now reverses isSortReversed and then returns the sort block.  Now try clicking on the column headers again in a class browser.  It works.  Yee-hah!

 

Now try bringing up another class browser.  You'll get the following walkback:

 

 

It's obvious something's awry in the binary filer.  I tried tracking it down in the debugger but couldn't find anything definitive.  I assume that the change to ListView, in particular, adding the instance variable, is the culprit.  And this makes some sense, after all, we wrote the instance to file with 6 instance variables, and now there are 7, so perhaps it reaches the end of stream because the count is off.

 

Version 4 update:  You'll get a different, but similar, walkback.  This one complains about STBViewProxy(Object)>>doesNotUnderstand, but you can see that it's coming from the binary filer.

 

Turn to the class side methods of ListViewColumn and you'll find two methods: #stbVersion and #stbConvertFrom:.  These are the first two methods you're likely to deal with in versioning instances.  #stbVersion returns the current version of this class -- the default is 0.  #stbConvertFrom: contains instructions on how to recreate prior-version instances.  First things first.  #stbVersion currently returns 3 (in my image, at least.  If yours is different, adjust accordingly.  Also, see below for changes to Dolphin version 4).  We change it to:

 

In ListViewColumn, class side:

stbVersion

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

 

        ^4

 

Try bringing up a class browser again and you'll see it works this time.  I was surprised this is all it took.  It appears we don't need to make any changes to #stbConvertFrom: but take a look at it anyway.  The comment indicates which instance variables were added with each version and the code indicates how to initialize the new instance variables in instances that were filed before the newer version.  In our case, we don't seem to need to initialize anything, but we still update the method, if for nothing else than to document the changes.  The new method is:

 

In ListViewColumn, class side:

stbConvertFrom: anSTBClassFormat

        "Convert from version 0 resource.

        Version 1 adds a getImageBlock instance variable.

        Version 2 adds an isAutoResize instance variable.

        Version 3 adds getInfoTipBlock instance variable.

        Version 4 adds isSortReversed instance variable."

 

        ^[:data | | newInstance ver |

                 newInstance := self basicNew.

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

                 ver := anSTBClassFormat version.

                 ver < 1 ifTrue: ["Leave the new getImageBlock inst. var. nilled"].

                 ver < 2 ifTrue: [newInstance instVarAt: 10 put: false].

                 ver < 3 ifTrue: ["Leave the getInfoTipBlock inst. var. nilled"].

                 ver < 4 ifTrue: ["Leave the isSortReversed inst. var. nilled"].

                 newInstance]

 

At this point, we've done pretty damn good.  With a handful of new and changed methods -- around a dozen lines of code -- we've added reverse sorting to all listviews -- past, current and future.  It's certainly also worth taking a look at the Dolphin Education Centre's writeup on binary filing, expecially the section on converting stb data after layout changes.

 

Version 4 update:  Dolphin version 4 adds a customDrawBlock instance variable to ListViewColumn, so we change the class definition and stb methods accordingly:

 

Object subclass: #ListViewColumn

        instanceVariableNames: 'text width alignment getTextBlock getSortValueBlock getContentsBlock compareBlock parent getImageBlock isAutoResize getInfoTipBlock customDrawBlock isSortReversed'

        classVariableNames: ''

        poolDictionaries: ''

        classInstanceVariableNames: ''

 

stbVersion

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

 

        ^5

 

stbConvertFrom: anSTBClassFormat

        "Convert from earlier version resource.

        Version changes:

                 1: Add a getImageBlock instance variable.

                 2: Addsan isAutoResize instance variable.

                 3: Add getInfoTipBlock instance variable.

                 4: Add customDrawBlock instance variable.

                 5: Add isSortReversed instance variable."

 

        ^[:data | | newInstance ver |

                 newInstance := self basicNew.

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

                 ver := anSTBClassFormat version.

                 ver < 1 ifTrue: ["Leave the new getImageBlock inst. var. nilled"].

                 ver < 2 ifTrue: [newInstance instVarAt: 10 put: false].

                 ver < 3 ifTrue: ["Leave the getInfoTipBlock inst. var. nilled"].

                 ver < 4 ifTrue: ["Leave the new customDrawBlock inst. var. nilled"].

                 ver < 5 ifTrue: ["Leave the isSortReversed inst. var. nilled"].

                 newInstance]

 

 

Changes to ListView and backing out (deleting) AcDcListView

 

There's a little more to do, though, so let's not rest on our laurels quite yet.  The behavior of the reverse sorting is not what I see in listviews in other programs.  For example, bring up Windows Explorer and click back and forth on different columns.  What I observed is that a column reverses its sort only when it is clicked twice in succession.  Furthermore, each column seems to have a default sort order (name, size and type are ascending, modified is reverse chronological) and it's this sort order that's used when the column is clicked for the first time or clicked after another column has just been clicked.  WinZip, another popular program, behaves the same, but it has different defaults.  Name, Type, and Modified are like Windows Explorer, but Size, Ratio, and Packed default to a descending sort.  It would seem that each application has its own criteria for sorting, though perhaps some common conventions are followed.  I brought up another program I have (BlackIce, a firewall) and it seems to have no defaults on the columns -- click twice on a column and it'll reverse its sort, but click back on another column and it'll use the last sort direction it had on that column.

 

In any event, our current implemention behaves differently from all three of these programs.  Currently, it doesn't make a difference if you've clicked twice in succession on a column.  It'll reverse its sort the next time you click on it.  Now that may be fine for some people and some applications but it's just not what most people expect.  They expect that if you click on one column, then click on another and then click back on the first, the column will return to the sort of the first click.  To me, the most straightforward way to implement this is in ListView, which is what the lastColSelected instance variable (currently in AcDcListView) is for.  It's ListView that will keep track of which column was last selected and what to do if it's selected again or another is selected.  And the logic for that goes in our good ole friend, ListView>>sortOnColumn:.

 

First though, we need to deal with AcDcListView.  It won't be needed anymore.  The new ListView does an even better job.  But we need to take care of some housekeeping.  We have at least 2 views that use AcDcListview -- PersonalMoneyShell and PersonalAccountShell.  If we delete AcDcListView, the PersonalMoney views won't work and we won't even be able to bring up the views to edit them.  So bring up VC on the two views and mutate the listview in each to a ListView, then save them.  The next thing is to move the helper method (#setupColumnsOn:) we wrote in Part 3 from AcDcListView to ListView.  The easiest way to do this is to drag it from the methods list in the class browser right pane, and drop it on class ListView in the left pane.  At this point you can delete AcDcListView.

 

We need to add the lastColSelected instance variable to ListView and add some sorting logic to #sortOnColumn:.  These are:

 

IconicListAbstract subclass: #ListView

        instanceVariableNames: 'primaryColumn columns viewMode lastSel iconSpacing lvStyle lastColSelected'

        classVariableNames: 'LvnMap RevertSelMessage'

        poolDictionaries: ''

 

lastColSelected

        "Answers the last column selected, a ListViewColumn."

 

        lastColSelected isNil ifTrue: [lastColSelected := self primaryColumn].

        ^lastColSelected

 

sortOnColumn: aListViewColumn

        "Sorts the receiver according to the sort block in aListViewColumn"

 

        aListViewColumn isSortReversed:

                 (self lastColSelected = aListViewColumn and: [aListViewColumn isSortReversed not]).

        lastColSelected := aListViewColumn.

 

        Cursor wait showWhile: [

                 self presenter beSorted: aListViewColumn rowSortBlock]

 

We use lazy initialization with lastColSelected so that it points to the first column.  The assumption is that the list will start out sorted according to the first column so that a click on the first column will reverse the sort.

 

The first line in #sortOnColumn: tells the column to use its reverse sort only if it was the last column selected and if it is not already reversed, otherwise use its regular sort.  Lest we forget, we also need to remove that temporary change we made in ListViewColumn>>rowSortBlock that toggles the state of isSortReversed.  We don't need that now since the controlling logic is in ListView.  Here it is:

 

In ListViewColumn:

rowSortBlock

        "Private - Answer a two argument block that can be used to compare

        two rows based on this column"

 

        self isSortReversed

                 ifTrue: [^[:a :b | (self sortBlock value: (self contentFromRow: a)

                         value: (self contentFromRow: b)) not]]

                 ifFalse: [^[:a :b | self sortBlock value: (self contentFromRow: a)

                                    value: (self contentFromRow: b)]]

 

The listviews now seem to exhibit familiar behavior.  One surprising thing is that although we added an instance variable to ListView, we don't see the walkback we saw with ListViewColumn when we bring up a new class browser.  Thus, there seems to be no need to implement #stbVersion or #stbConvertFrom: in ListView.

 

The final change to ListView involves some minor changes to the helper method, #setupColumnsOn:.  We remove the line that sets a column's sortBlock to SortedCollection since the reason for it being there is no longer valid.  We also add the two lines of code that turn gridlines on and that remove the icons from column one.  These were formerly in AcDcListView initialize, but this is as good a place as any for them.

 

In ListView:

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

                                 getContentsBlock: (Compiler evaluate: '[:model | model ', aspect, ']' logged: false)].

                 self primaryColumn text: (self allColumns at: 1) text.

                 self hasGridLines: true.

                 self imageManager: nil]

 

One other change is to restore a change we made to LVCOLUMN.  Again, the reason for having changed it previously is no longer valid (though it doesn't hurt to leave it there and in fact, perhaps the method is better with the changed logic).  If you wish, restore the method to:

 

In LVCOLUMN:

fromColumn: aListViewColumn in: aListView

        "Answer an LVCOLUMN generated from aListViewColumn."

 

        ^self new

                 text: aListViewColumn text;

                 width: aListViewColumn basicWidth;

                 alignment: aListViewColumn alignment;

                 yourself

 

And with that, we're done with the changes to ListView and ListViewColumn needed to implement bi-directional sorting.

 

 

Changes to PersonalMoney Views

 

Unfortunately, and I really hate to say this since I thought we were done with sorting problems in PersonalMoney, we need to go back and deal with nil again.  Previously we relied on the sort block in AcDcListView to be tolerant of nil, but that's no longer the case.  So for each column that can have nil values (Debits and Credits in PersonalAccountShell's default view), change its sortBlock to "[:a :b | a isNil or: [b notNil and: [a <= b]]]".  And then, if you wish, change the sortBlock for Date to "[:a :b | a > b]" for a default reverse chronological sort.

 

You may also need to set imageManager to nil for each of the listviews (in PersonalMoneyShell and PersonalAccountShell's default views).  A tip on this:  imageManager is an unpublished aspect, i.e., it doesn't appear in VC.  If you add the following line to IconicListAbstract class>>publishedAspectsOfInstances

        add: (Aspect name: #imageManager);


then it will subsequently show up as an aspect (of listviews) in the VC.  You can then change the aspect value in VC to nil to get rid of the icons or, to restore them, you can evaluate "IconImageManager current".

 

 

Next Steps

 

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

 

Written by Louis Sumberg

Last updated December 2000

 

 

Back to Part 3              Onwards to Part 5