Extending the PersonalMoney Application
Part 4
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.
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)]]
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]
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.
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".
If you've
found this exercise helpful or have questions or suggestions, let me know -- I
welcome comments, criticism and feedback.
Back to Part 3 Onwards to
Part 5