As an application developer, I want to provide that capability to my users and I want it to be easy to do, in a uniform manner across applications. Whereas model data may be stored in a database or a binary file, I've generally found it convenient to store user interface aspects and options in a text file. A text file is easy to read and, if necessary, easy to edit. A text file is easy to transport across directories within a system or from one system to another.
The first part of this exercise deals with implementing reading and writing of key/value pairs to text files. The second part implements a mechanism that makes it easy to enable automatic saving and restoring of view aspects and application preferences. Although the second part uses the text file read/write from the first part, it could easily use a different read/write mechanism.
[LasPersonalAccountTransactionDialog]
Screen=#(1024 768 96 96)
DialogView placement=#(0 297 241 727 526)
[LasPersonalAccountShell]
Screen=#(1024 768 96 96)
ShellView placement=#(0 6 -3 697 736)
transactions column order=#(1 2 3 4 5 6)
transactions column widths=#(70 206 125 70 70 100)
[LasPersonalMoneyShell]
Screen=#(1024 768 96 96)
ShellView placement=#(0 15 91 297 306)
accounts column order=#(1 2 3 4)
accounts column widths=#(100 0 100 70)
MRU filename=C:\Dolphin4\Build1\Louis.pm
MRU account=2
In this example, there are three sections -- PersonalMoneyShell, PersonalAccountShell, and PersonalAccountTransactionDialog -- containing five, three, and two key=value pairs respectively.We'll see later exactly what these lines mean in the context of this exercise, but in brief the section names correspond to PersonalMoney's three shell classnames. Each section contains values for the screen size and resolution when the topview (dialogview or shellview) was last displayed, as well as the topview's placement, which includes position and size. There are values that reflect the column ordering and column size for listviews and there are also values for the most recently used (MRU) filename and account.
Here's a diagram that gives another idea of where we're going with this. In this case, the top level MVP triad is shown along with two sections from an inifile. "Presenter" is a Shell whose class name is 'AnApp'. There are two shellviews for this application -- 'Default View' and 'Alt View'. Presenter data (such as filenames, selections, and options) is stored in a section whose name is the shell's class name (AnApp). View data (such as placement, column ordering, arrangement, and font) is also stored in the ini file. For the default view, data is stored in the same section as the presenter data. (Since most top presenters have a single corresponding topview, it's convenient to have all data in one section. Plus, as we'll see, there's a pragmatic reason for this design.) View data for each non default view is stored in its own separate section, whose name is the shell's class name concatenated with a sub-identifier (the view resource name).

An ini file, being a text file, presents a simple way to store small amounts of application data. It is easily understood and is easily manipulated (e.g., with Notepad), whether inside or outside the Dolphin image or application executable file. Its origins go back to MS-DOS days, though in recent years Microsoft has been encouraging applications to use the Windows Registry instead of ini files. To me the big advantage of an ini file over the Registry is that I can ask a user to take a look at an ini file and edit it if they need to fix or change something, whereas I would never suggest that a user edit the Registry. The other advantage is that the ini file can reside in the same directory as the application so it's easy to find, and if you want to move the application to another directory, move the ini file also. There's no need to fuss with the Registry. However, for those who do want to use the Registry it should be a fairly easy thing to modify, extend or subclass the Ini class developed in this exercise to use Windows Registry API functions.
BOOL WritePrivateProfileString( LPCTSTR lpAppName, // section name LPCTSTR lpKeyName, // key name LPCTSTR lpString, // string to add LPCTSTR lpFileName // initialization file );In other words, this function takes four string parameters and returns a boolean. It turns out that any of the strings can be nil (which is fine when passing Dolphin strings), so taking that into account we can implement this method:
In KernelLibrary: writePrivateProfileString: sectionOrNil key: keyOrNil value: valueOrNil filename: filenameString "Write the specified key and value to the specified section in the specified file. Answer true if successful, else false. BOOL WritePrivateProfileString( LPCTSTR lpAppName, // section name LPCTSTR lpKeyName, // key name LPCTSTR lpString, // string to add LPCTSTR lpFileName // initialization file );" <stdcall: bool WritePrivateProfileStringA lpstr lpstr lpstr lpstr> ^self invalidCallNotice the one-to-one correspondence between items in the Windows function declaration and the Dolphin method. The next to last line in the method says use the standard 32-bit calling convention (stdcall) to call a function that will return a boolean (bool). The function to call is WritePrivateProfileStringA (the A suffix is for the ascii version, as opposed to the unicode version, which has a W suffix), and pass it four null-terminated strings (lpstr), which Dolphin strings are. Other than making sure there are four parameters in the method header and their order corresponds to the order of the API function's parameters, we have license in what to call the method itself, but it's good to stay close to the names used in the API declaration.
In a similar manner, you can look up the declaration for GetPrivateProfileString and do the mechanical translation, coming up with the next method. Again, you can see the mapping between the API declaration (in the comment) and the method itself.
In KernelLibrary: getPrivateProfileString: sectionString key: keyString default: defaultOrNil buffer: returnString bufferSize: nSize filename: filenameString "Retrieves the value for the given key in the given section in the given file and places it in the returnString buffer (or places the given default there). The buffer size is nSize. Answers the size of the string copied to the buffer. DWORD GetPrivateProfileString( LPCTSTR lpAppName, // section name LPCTSTR lpKeyName, // key name LPCTSTR lpDefault, // default string LPTSTR lpReturnedString, // destination buffer DWORD nSize, // size of destination buffer LPCTSTR lpFileName // initialization file name );" <stdcall: dword GetPrivateProfileStringA lpstr lpstr lpstr lpstr dword lpstr> ^self invalidCall
KernelLibrary default writePrivateProfileString: 'Ini test section' key: 'Date' value: '1/1/01' filename: 'TestIni.ini'According to the MSDN notes, if the filename supplied "does not contain a full path and file name for the file, WritePrivateProfileString searches the Windows directory for the file. If the file does not exist, this function creates the file in the Windows directory." On my system, the above message send evaluated to true and created a file named 'TestIni.ini' in the Windows directory. The file contents are:
[Ini test section] Date=1/1/01Evaluate the following to see for yourself:
"Display the contents of the file." testfile := File fullPathOf: 'TestIni.ini' relativeTo: SessionManager current windowsDirectory. (FileStream read: testfile) contents.Remember that all parameters to #writePrivateProfileString: must be strings (or nil). This means, for example, you can't supply a Date object as the value (e.g., Date today). If you do, you'll get a walkback complaining about coercing the object to an lpstr. The getPrivateProfile functions are not so simple. For example, evaluate (display) the following in a workspace:
KernelLibrary default getPrivateProfileString: 'Ini test section' key: 'Date' default: Date today displayString buffer: (String new: 100) bufferSize: 100 filename: testfile.For me this returned the number 6, which is the size of the string '1/1/01'. While that's nice and good, it doesn't help much since the buffer (String new: 100) is not accessible to read the 6 characters from. This points to that the getPrivateProfile function needs some preparation before and after it's called. Specifically, and this is a common thing to do with Windows API calls, you need to create a buffer (a "goodsized" string) to which the API function will write a returned value. You pass the buffer as one of the input parameters, along with the size of the buffer. The Windows function then writes the returned string to the buffer and returns the number of characters copied as the function's return value. Thus, in the workspace again, evaluate (display) the following:
buffer := String new: 100. retSize := KernelLibrary default getPrivateProfileString: 'Ini test section' key: 'Date' default: Date today displayString buffer: buffer bufferSize: buffer size filename: testfile. buffer leftString: retSizeThis should return the same date string that was written to the file just above ('1/1/01'). Change the key to 'Dte' and try it again and you'll get today's date, the default input parameter. Keep the key as 'Dte' and change the default (Date today displayString) to nil and try it again and you'll get an empty string.
Our intent is to wrap the KernelLibrary methods in more usable methods. The first step is to create a new class (Ini) and provide methods that do the pre- and post-processing seen above. Here's the class definition.
Object subclass: #Ini instanceVariableNames: '' classVariableNames: '' poolDictionaries: '' classInstanceVariableNames: ''For now, there are no instance variables so we'll install the basic read and write methods on the class side. In the case where a parameter can be a string or nil, we test the parameter for nil -- if it's nil, then pass nil, otherwise pass the value as a string. In the case where a parameter should only be a string we ensure it's a string. Thus, #writeKey:value:section:filename:, which expects strings for section and filename, and strings or nil for key and value, is written as:
In Ini, class side: writeKey: keyOrNil value: objectOrNil section: section filename: filename "Private - Write the specified key and value to the specified section in the specified file. Answer true if successful, else false." filename isNil ifTrue: [^false]. ^(KernelLibrary default writePrivateProfileString: section displayString key: (keyOrNil isNil ifTrue: [nil] ifFalse: [keyOrNil displayString]) value: (objectOrNil isNil ifTrue: [nil] ifFalse: [objectOrNil displayString]) filename: filename)Note that key and value are converted to strings if not nil. This means you can pass in numbers or other data types as the key or value and they will be written to file as text. When reading them back, it's up to the application to convert them back to the proper data type. WritePrivateProfileString has a few special features. For example, if you pass it a nil as the value, not only does it not write anything to the file but it deletes the existing key/value, if any. If you pass it a nil as the key, it deletes the entire section from the file.
In a similar way, using the buffer technique from the workspace (but making it a lot bigger) and ensuring strings and nils are accounted for, here is the readKey: method:
In Ini, class side: readKey: keyOrNil default: defaultOrNil section: sectionOrNil filename: filename "Answer a string - either the value at the specified key, or if the key doesn't exist, the defaultOrNil." | buffer retSize | filename isNil ifTrue: [^String new]. buffer := String new: 32000. retSize := KernelLibrary default getPrivateProfileString: (sectionOrNil isNil ifTrue: [nil] ifFalse: [sectionOrNil displayString]) key: (keyOrNil isNil ifTrue: [nil] ifFalse: [keyOrNil displayString]) default: (defaultOrNil isNil ifTrue: [nil] ifFalse: [defaultOrNil displayString]) buffer: buffer bufferSize: buffer size filename: filename. ^buffer leftString: retSizeGetPrivateProfileString also has some special features. If you pass it a nil as the key, it returns all the key/value pairs in the section. If you pass it a nil as the section name, it returns all the section names in the file. According to the API notes, default must always be a string, but I've found that nil, when needed, works fine. This is a good time to use one of these special features -- add another class side method, one that returns all the section names in the ini file.
In Ini, class side: sectionNamesInFile: filename "Answer a collection containing the names of all sections in the specified file." | col strm str | col := OrderedCollection new. strm := (self readKey: nil default: nil section: nil filename: filename) readStream. [strm atEnd or: [(str := strm upTo: Character null) isEmpty]] whileFalse: [col add: str]. ^colThis method calls Ini class>>#readKey: but passes nil as the section. As noted above, in this case getPrivateProfileString returns all the section names in the ini file. The string returned by #readKey: contains a number of section names. Each name ends with a null character and the final name ends with two nulls. So after Windows copies the names to the buffer, we stream over the buffer, picking out each name and adding it to the collection that the method returns.
Go back to the workspace now and test the new methods:
"Read a value from the file. This should return the value previously written." Ini readKey: 'Date' default: Date today section: 'Ini test section' filename: testfile. "Read a value where the key doesn't exist. This should return the default." Ini readKey: 'Dte' default: Date today section: 'Ini test section' filename: testfile. "Read a value and convert it from a string." Date fromString: (Ini readKey: 'Date' default: Date today section: 'Ini test section' filename: testfile). "Write a second key=value line in a new section." Ini writeKey: 'Option 1' value: 10 section: 'Ini test section 2' filename: testfile. "Read all the section names from the specified file." Ini sectionNamesInFile: testfile.When done, you can delete the file (from the Windows directory) by evaluating:
File delete: testfile.
Object subclass: #Ini instanceVariableNames: 'filename section' classVariableNames: '' poolDictionaries: '' classInstanceVariableNames: ''Now add the following standard accessor methods:
In Ini: filename "Answer the receiver's filename." ^filename filename: aString "Set the receiver's filename to aString." filename := aString section "Answer the receiver's section name." ^section section: aString "Set the receiver's section name to aString." section := aStringThe two new instance variables allow you to specify a filename and section name that will be used by an Ini instance. Put in another light, this means that an Ini instance represents a section in an ini file. Add a method now to initialize these two variables.
In Ini: initialize "Private - initialize the receiver." super initialize. filename := File change: SessionManager current imageFileName extension: 'ini'. section := 'Default'Thus, the default section name is 'Default'. The default for filename is a bit trickier. Since an ini file is generally in the same directory as the application and it has an 'ini' extension, we create a filename based on the image filename. For example, if the image filename is 'C:\My Documents\Dolphin Smalltalk 4.0\MyApp.img' or the deployed application executable is 'C:\My Documents\Dolphin Smalltalk 4.0\MyApp.exe', the default ini filename should be 'C:\My Documents\Dolphin Smalltalk 4.0\MyApp.ini'. We use SessionManager>>#imageFileName to retrieve the full path and filename of the image or executable that is running, and then use File>>#change:extension: to replace 'img' or 'exe' with 'ini'.
Now we add two instance methods that reference the basic class side methods we wrote earlier:
In Ini: readString: key default: default "Answer a string - either the value at the specified key or default if the key doesn't exist." ^self class readKey: key default: default section: self section filename: self filename writeKey: key value: value "Write the specified key and value using the receiver's section and filename. Return true if successful, else false." ^self class writeKey: key value: value section: self section filename: self filenameThe first method is named #readString:, not #readKey:. This is to highlight that it returns a string. It makes a little more sense when we later add helper methods, such as #readNumber:. Though asymmetrical, I named the second method #writeKey:, mainly because there's no need for any type-specific write methods, such as #writeNumber:. You can create an ini instance and try the methods in a workspace:
"Create an Ini instance. Inspect the following line." ini := Ini new. "Write a key=value line." ini writeKey: 'Date' value: '1/1/01'. "Read a value from the file. This should return the value previously written." ini readString: 'Date' default: Date today. "Read a value where the key doesn't exist. This should return the default." ini readString: 'Dte' default: Date today. "Read a value and convert it from a string." Date fromString: (ini readString: 'Date' default: Date today). "Write a second key=value line in a new section." ini section: 'Ini test section 2'. ini writeKey: 'Option 1' value: 10. "Read all the section names from the specified file." ini class sectionNamesInFile: ini filename.
For example, we already know that we can write any object to an ini file. Just send a key and the object to writeKey, which will write a string representation of the object at the specified key. To retrieve the value that was written, you use #readString:. Converting the returned string to a number, a boolean, an array, a font, or any type, are made after the message to #readString:. As it is now, that conversion must be done by the caller (i.e., the application), but we can add helper methods that do the conversion first. Here then are the first set of convenience (aka helper or derived) methods:
In Ini: readNumber: key default: default "Answer a number -- either the value at the specified key or default if the key doesn't exist." ^(self readString: key default: default) asNumber readBoolean: key default: default "Answer a boolean -- either the value at the specified key or default if the key doesn't exist." ^(self readString: key default: default) = 'true' readArray: key default: default "Answer an array -- either the value at the specified key or default if the key doesn't exist." ^Compiler evaluate: (self readString: key default: default) logged: falseThese are fairly simple: #readNumber takes the returned string and turns it into a number, #readBoolean does the same, returning a Boolean, and #readArray returns an Array. #readArray looks fancier than the other two -- it expects the value returned to be in Array format, e.g. #(123 345 457) and Compiler>>#evaluate: can convert that string representation to an array.
The next four methods follow easily -- they serve to provide a default value for each of the read methods:
readString: key "Answer a string - either the value at the specified key or an empty string if the key doesn't exist." ^self readString: key default: String new readNumber: key "Answer a number - either the value at the specified key or 0 if the key doesn't exist." ^self readNumber: key default: 0 readBoolean: key "Answer a boolean - either the value at the specified key or false if the key doesn't exist." ^self readBoolean: key default: false readArray: key "Answer an array -- either the value at the specified key or an empty array if the key doesn't exist." ^self readArray: key default: Array newThe curious among you may be wondering "Why these four (String, Number, Boolean, and Array)? Why not Symbol or Collection? Where does it stop?" The short answer to these questions is that with #writeKey:value: and these four read methods you can save and restore most of an application's presenter and view state information conveniently, through an Ini instance. It represents a compromise between convenience and pure object-oriented design, similar to when you might send "aString asNumber", rather than "Number fromString: aString". You may well want to add some more methods to Ini (for simple object types), such as #readSymbol:, but for complex objects I recommend they not be implemented in Ini. We'll see examples of these later -- e.g., Font class>>#iniRead:key: which returns a font. Note that although all of this has the flavor of serialization, it is not meant to be as complete as a scheme that fully saves and restores objects. Finally, the reason I chose Array, rather than OrderedCollection, is that anArray printString writes a simple concise representation of a collection that can be easily recreated through Compiler>>#evaluate:logged: -- put another way, I was too lazy to write a #readCollection: method that would parse the output from aCollection printString.
Update (April '04): Using class Compiler in #readArray: means that a ToGo application has a dependency on Dolphin's Development dll, so I've changed a couple of methods and added a couple in order that ToGo applications using class Ini do not need to have that dll redistributed. Below are the changed and new methods:
readArray: key "Answer an array -- either the value at the specified key or an empty array if the key doesn't exist. Values in array must be of classes String, Boolean, and/or Number." ^self readArray: key default: Array new readArray: key default: default "Answer an array -- either the value at the specified key or default if the key doesn't exist. Values in array must be of classes String, Boolean, and/or Number." ^self parseArrayString: (self readString: key default: default) parseArrayString: aString "Private - Answer an Array whose elements are of classes String, Boolean, and/or Number." | strm col arr ch | strm := (aString midString: aString size - 3 from: 3) readStream. col := OrderedCollection new. [strm atEnd] whileFalse: [ch := strm peek. ch = $' ifTrue: [strm next. col add: '''' , (strm upTo: $') , ''''. strm upTo: $ ] ifFalse: [col add: strm nextWord]]. arr := Array new: col size. col keysAndValuesDo: [:i :e | arr at: i put: (self parseArrayItem: e)]. ^arr parseArrayItem: aString "Private - Answer a String, Boolean, or Number." aString first = $' ifTrue: [^aString midString: aString size - 2 from: 2]. aString = 'false' ifTrue: [^false]. aString = 'true' ifTrue: [^true]. ^aString asNumberRounding out the set of helpers methods are those that return data "en masse" and those that delete data. For example, we've already seen #sectionNamesInFile: on the class side -- it would be nice to have an instance side method. It'd be helpful to have a method that returns all of the keys in a section, a method that returns the contents (all of the key=value pairs) of a section and one that returns the entire contents of a file. Here are the remaining methods:
In Ini: sectionNamesInFile "Answer a collection containing all of the section names in the receiver's file." ^self class sectionNamesInFile: self filename readSection "Answer a collection of associations that represents the receiver's current section" | col strm str | col := OrderedCollection new. strm := (self readString: nil) readStream. [strm atEnd or: [(str := strm upTo: Character null) isEmpty]] whileFalse: [ col add: (Association key: str value: (self readString: str))]. ^col keys "Answer a collection containing the keys in the receiver's section." ^self readSection collect: [:assoc | assoc key] keyExists: key "Answer true if key exists in the receiver's section, else false." ^self keys includes: key contents "Answer the contents of the file." ^self filename isNil ifTrue: [String new] ifFalse: [(FileStream read: self filename) contents] removeSection "Remove the current section from the receiver's file." self writeKey: nil value: nil removeKey: key "Remove the specified key from the current section in the receiver's file." self writeKey: key value: nilIn testing these methods, I found that Ini>>#contents did not work quite right. If you display the file contents and then modify the file (e.g., delete a line or section), #contents still returns the unmodified file contents. I tried various ways to refresh or flush the file buffer, but they didn't do the job or complained that the file was locked. I ended up changing #writeKey:, adding a second call to writePrivateProfileString (in KernelLibrary) and passing nil values except for the filename.
In Ini, class side: writeKey: keyOrNil value: objectOrNil section: section filename: filename "Write the specified key and value to the specified section in the specified file. Answer true if successful, else false." | ret | filename isNil ifTrue: [^false]. ret := (KernelLibrary default writePrivateProfileString: section displayString key: (keyOrNil isNil ifTrue: [nil] ifFalse: [keyOrNil displayString]) value: (objectOrNil isNil ifTrue: [nil] ifFalse: [objectOrNil displayString]) filename: filename). KernelLibrary default writePrivateProfileString: nil key: nil value: nil filename: filename. ^ret
The design is fairly simple and straightforward. Here are a few pictures to help you understand what I'll be talking about:


The shell contains two cards, one that shows the contents through listviews and the other that shows the contents in a textedit. The File menu has menuitems that let you choose and open an ini file, close an open file, and exit the application. The Options menu has one menuitem that lets you choose a font. In the first picture above, the file loaded has 8 sections, the last section is selected, and the key/value pairs in that section are shown in the listview on the right. The second picture shows the ini file contents in a textedit, scrolled to around the middle of the file.
As usual, for me at least, though I started with a visual design, it's good to build the presenter first, especially since you can't save a View Composer resource till you have the presenter class defined anyway. For this application we'll subclass DocumentShell and use some of its builtin file management methods. We'll add four instance variables, three for presenters and one for an Ini instance. Of the presenters, there are two list presenters that manage the list of sections and the list of key/value pairs on the first card and there's a text presenter that manages the text on the second card. We'll also add code to save and restore presenter and view state, such as view placement, column widths, MRU filename, and font. Here's the entire class definition, with additional comments below:
DocumentShell subclass: #InifileBrowser
instanceVariableNames: 'sectionsPresenter sectionPresenter ini textPresenter'
classVariableNames: ''
poolDictionaries: ''
classInstanceVariableNames: ''
In InifileBrowser:
createComponents
"Create the presenters contained by the receiver"
super createComponents.
sectionsPresenter := self add: (ListPresenter on: ListModel newEquality) name: 'sections'.
sectionPresenter := self add: ListPresenter new name: 'section'.
textPresenter := self add: TextPresenter new name: 'contents'
createSchematicWiring
"Create the trigger wiring for the receiver"
super createSchematicWiring.
sectionsPresenter when: #selectionChanged send: #onSectionSelected to: self
fileLoad
"Set the receiver's model to be the receiver's file."
self model: self filename
fileClose
"Set the receiver's model to nil."
self model: nil
model: aFilename
"Set the model associated with the receiver."
self filename: aFilename.
super model: self filename.
ini := Ini new filename: self filename.
sectionsPresenter list: ini sectionNamesInFile.
sectionPresenter list: OrderedCollection new.
textPresenter model: ini contents.
sectionsPresenter selectionByIndex: 1 ifAbsent: []
chooseFont
"Popup the font dialog to allow selection of font for the text in the receiver."
self view font: (FontDialog showModalOn: self view actualFont)
removeKey
"Remove the selected key from the associated file. Reset submodels."
sectionPresenter hasSelection ifTrue: [
ini removeKey: sectionPresenter selection key.
sectionPresenter model remove: sectionPresenter selection.
textPresenter model: ini contents]
removeSection
"Remove the selected section from the associated file. Reset the receiver's model."
sectionsPresenter hasSelection ifTrue: [
ini removeSection.
self model: self model]
onSectionSelected
"Present the items in the newly selected section."
ini section: sectionsPresenter selectionOrNil.
sectionPresenter list: ini readSection
In InifileBrowser, class side:
fileTypes
"Answer an Array of file types that can be associated with this class of document."
^#( ('Ini files (*.ini)' '*.ini')
('All Files (*.*)' '*.*') )
We create the presenters in #createComponents, giving them names ('sections', 'section', and 'contents') that will tie them to the subviews. In #createSchematicWiring we add an observer that lets us know when a different section has been chosen. In #model:, which is passed the ini file we're browsing, we link sectionsPresenter to the ini file's section names, reset sectionPresenter's model to an empty ListModel, and set textPresenter to manage the file contents. If there is at least one section in the sections list, we select it.
When a new section is selected, sectionsPresenter's listmodel (because of the observer we installed) sends #onSectionSelected back to the inifile browser, which populates the sectionPresenter's model with the key/value associations in the selected section. #fileLoad is called by #fileOpen, a command inherited from DocumentShell, which we'll use to prompt for a file. DocumentShell>>#fileLoad expects a binary stream, so we override it here, sending the chosen filename to #model:.#removeSection removes the selected section from the file and by resetting the browser's model, resets all the sub presenters and views. Similarly, #removeKey removes the selected key from the file and resets the sectionPresenter list and textPresenter. Finally, on the class side, we supply a #fileTypes method which DocumentShell uses to filter filenames when prompting to open a file.
These are the methods that make the browsing part of this application work. The next section implements the save and restore functionality.
Now it's time to create the view -- here's a quick walkthrough of its subviews and aspects, with the view hierarchy from the VC shown below left.

The top view is a Shellview that contains a CardContainer (named 'cards') that holds two cards -- a ContainerView and a MultiLineEdit (named 'contents)'. The arrangement aspects for the two cards are 'Sections' and 'File' -- these show up as the tab names. The container view contains two listviews (named 'sections' and 'section') and a splitter. The layoutManager for the shellview is a ProportionalLayout. The listview on the left ('sections') has one column whose text aspect is 'Sections'. We don't need to specify a getContentsBlock for the column since the model is a simple list of strings. The listview on the right ('section') has two columns, with text aspects 'Key' and 'Value'. The getContentsBlock for column 'Key' is [:model | model key] and for column 'Value' is [:model | model value].
The menu bar contains a File menu with three commands: Open, which calls #fileOpen, Close, which calls #fileClose, and Exit, which calls #exit. The menu bar also contains an Options menu with one command, Font, which calls #chooseFont. The sections listview contains a context menu with a single menu item, 'Remove section', which calls #removeSection. The section listview contains a context menu with a single menu item, 'Remove key', which calls #removeKey.
Go ahead and build the view, then save it as 'Default view' under InifileBrowser. Run the browser and open the file you created earlier from the workspace. (The file should be in the same directory as your image.) You can also open other files too, of course, and view their contents. You'll probably find plenty of ini files in the Windows directory.
In InifileBrowser: onViewClosed "Sent by the receiver's view when it has been closed" super onViewClosed. self iniWrite: (Ini new section: self class name) onViewOpened "Received when the receiver's view has been connected." super onViewOpened. self iniRead: (Ini new section: self class name) iniWrite: anIni "Save the receiver's settings to file." anIni writeKey: 'MRU filename' value: self filename. anIni writeKey: 'sections selection' value: sectionsPresenter selectionOrNil. iniRead: anIni "Restore the receiver's settings from file." self model: (anIni readString: 'MRU filename'). sectionsPresenter selection: (anIni readString: 'sections selection') ifAbsent: [nil].#onViewClosed and #onViewOpened call #iniWrite: and #iniRead: respectively, each passing a new Ini instance whose section name is set to the shell's class name (in this case 'InifileBrowser'). #iniWrite: saves the browser's filename and the currently selected section name (if any) to file and #iniRead: restores these.
Start the browser again, open the file you created earlier in the workspace and then close the browser. Start the browser again and it should now automatically reload the file you last used. Click on the 'File' tab and you should see something like this (though the filename in the shell caption will be different):

The key (no pun intended) thing here is the 'InifileBrowser' section with its two lines. 'MRU filename' contains the name of the last file opened and that file will automatically be loaded by #iniRead:. 'sections selection' contains the selected item in the sections listview and that selection will automatically be selected when the application starts up. You can test this by selecting a different item in the sections list, closing the browser and then restarting the browser.
The next step is to save and restore a different type of data, something a bit more complex, for example, a font. I hinted earlier on how to do this, i.e., not by adding a #readFont: method to Ini but rather, in the true, tried, and venerable Smalltalk tradition, we ask the font instance to write itself to the ini file and then we ask the Font class to create a font instance based on what it reads from the ini file. The key aspects that define a font are its name, its point size, and whether it's bold or italic. Thus, we add the following two methods:
In Font: iniWrite: anIni key: key "Write a textual representation of the receiver to anIni file at key." anIni writeKey: key value: (Array with: self name with: self pointSize with: self isBold with: self isItalic) In Font, class side: iniRead: anIni key: key default: default "Answer an instance of the receiver, formed from either the value at key in anIni file, or default." | arr | ^(arr := anIni readArray: key) isEmpty ifTrue: [default] ifFalse: [(self name: (arr at: 1) pointSize: (arr at: 2)) isBold: (arr at: 3); isItalic: (arr at: 4); yourself]In the first method, the font instance sends #writeKey:value: to the ini instance, passing an array, e.g., #('Arial' 10 false false) as the value. In the second method, class Font asks the ini instance for the array at the specified key. If found (i.e., not empty), it returns a new font instance based on the array values. If not found, it returns the default, which should be a font instance.
Accordingly, we revise the read and write methods in the browser application:
In InifileBrowser: iniWrite: anIni "Sent by the receiver's view when it has been closed." anIni writeKey: 'MRU filename' value: self filename. anIni writeKey: 'sections selection' value: sectionsPresenter selectionOrNil. self view font iniWrite: anIni key: self view class name, ' font' iniRead: anIni "Restore the receiver's state from anIni file." self model: (anIni readString: 'MRU filename'). sectionsPresenter selection: (anIni readString: 'sections selection') ifAbsent: [nil]. self view font: (Font iniRead: anIni key: self view class name, ' font' default: self view font)We need to add another method before proceeding. Notice the line "self view font iniWrite:" in #iniWrite: above. If the font is nil, this line won't work and you'll get a walkback saying that UndefinedObject does not understand #iniWrite:key:. To take care of this, add the following method to Object:
In Object: iniWrite: anIni key: key "Save the receiver to file." anIni writeKey: key value: selfTry it -- start the browser and choose menu Options/Font, then pick a different font. You should see the text in the browser change to the new font. Close the browser and reopen it. The text should be in the new font and if you look at the InifileBrowser section, you should see a new key/value line with the new font. If you want to restore the font to the default, choose menu Options/Font again and click on Cancel. This will set font to nil which means the font for the browser defaults to that of its parent, in which case when you close the browser, it saves nil as the font which, as we've seen previously, means that it will delete the key/value line from the ini file.
Now we move on to saving and restoring view state. The first things I'd like to look at are saving and restoring:
In WINDOWPLACEMENT: iniWrite: anIni key: key "Save the receiver's state to anIni file." | rect | rect := self rcNormalPosition asRectangle. anIni writeKey: key value: (Array with: self showCmd with: rect left with: rect top with: rect right with: rect bottom) In WINDOWPLACEMENT, class side: iniRead: anIni key: key default: default "Answer an instance of the receiver, formed from either the value at key in anIni file, or default." | arr | ^(arr := anIni readArray: key) isEmpty ifTrue: [default] ifFalse: [self new showCmd: (arr at: 1); rcNormalPosition: (RECT left: (arr at: 2) top: (arr at: 3) right: (arr at: 4) bottom: (arr at: 5))]Very similar to the Font methods, in the first method, the WINDOWPLACEMENT instance sends #writeKey:value: to the ini instance, passing an array, e.g., #(0 192 185 801 510), as the value. In the second method, class WINDOWPLACEMENT asks the ini instance for the array at the specified key. If found (i.e., not empty), it returns a new WINDOWPLACEMENT instance based on the array values. If not found, it returns the default, which should be a WINDOWPLACEMENT instance.
Before proceeding, consider what happens if you change the screen size and resolution (e.g., buy a new monitor) in between using the application. The old settings may be inappropriate. So we also save the current screen size and resolution, which entails adding the following method:
In DesktopView: iniWrite: anIni "Save the receiver's size and resolution to file." | extent res | extent := self rectangle extent. res := self resolution. anIni writeKey: 'Screen' value: (Array with: extent x with: extent y with: res x with: res y)At this point, as we did just above, we could revise InifileBrowser #iniWrite: and #iniRead to explicitly save and restore the view's placement, but instead, in a wonderful bit of redirecting, we ask the view to save and restore itself. To do that, we first need to add two methods to ShellView:
In ShellView: iniWrite: anIni "Save the screen size and the receiver's placement to anIni." DesktopView current iniWrite: anIni. self placement iniWrite: anIni key: self class name, ' placement' iniRead: anIni "Restore the receiver's state from anIni file." self placement: (WINDOWPLACEMENT iniRead: anIni key: self class name, ' placement' default: self placement)Note that I don't have any code that actually checks the screen size and resolution and does something if the old is different from the current. Again, I plead laziness. One approach is to compare the old to the new -- for example, if the new screen size is smaller than the old, then don't restore the saved placement, or at least ensure that at least part of the window will show up on the screen.
Revise the application read/write methods to save and restore view state:
In InifileBrowser: iniWrite: anIni "Sent by the receiver's view when it has been closed." anIni writeKey: 'MRU filename' value: self filename. anIni writeKey: 'sections selection' value: sectionsPresenter selectionOrNil. self view font iniWrite: anIni key: self view class name, ' font'. self view iniWrite: anIni iniRead: anIni "Restore the receiver's state from anIni file." self model: (anIni readString: 'MRU filename'). sectionsPresenter selection: (anIni readString: 'sections selection') ifAbsent: [nil]. self view font: (Font iniRead: anIni key: self view class name, ' font' default: self view font). self view iniRead: anIniTry it again. Open the browser, resize the shell window and move it, then close it and reopen it. It should appear at the new position with the new size.
Next we address saving and restoring a listview's column order, column widths, and arrangement. This should start looking like a pattern because once again, we add two methods, this time to ListView, that save and restore view state.
In ListView: iniWrite: anIni "Save the receiver's state to anIni file." anIni writeKey: self name, ' column order' value: self columnOrder. anIni writeKey: self name, ' column widths' value: (self allColumns collect: [:col | col width]) asArray. anIni writeKey: self name,' arrangement' value: self arrangement iniRead: anIni "Restore the receiver's column order and column widths from anIni." | columnOrder columnWidths | columnOrder := anIni readArray: self name, ' column order'. (columnOrder size = self columnOrder size) ifTrue: [ columnWidths := anIni readArray: self name, ' column widths'. columnOrder do: [:index | (self columnAtIndex: index) width: (columnWidths at: index)]. self columnOrder: columnOrder]. self arrangement: (anIni readNumber: self name, ' arrangement' default: self arrangement)Again, we could revise InifileBrowser's read and write methods, but this time we take a different tack, similar to that of an object streaming itself. We go back to the ShellView read/write methods and have the shellview read and write all of its named subviews. The reason for dealing with only named subviews is that we use the view's name to form the key under which to save the value. Fortunately, in most cases, unnamed subviews (e.g., splitters) don't have any state that needs to be saved.
In ShellView: iniWrite: anIni "Save the screen size and the receiver's placement to anIni. Inform all named subviews to save their state to anIni." DesktopView current iniWrite: anIni. self placement iniWrite: anIni key: self class name, ' placement'. self allSubViews do: [:subView | subView name notNil ifTrue: [subView iniWrite: anIni]] iniRead: anIni "Restore the receiver's placement. Restore the state of all subviews." self placement: (WINDOWPLACEMENT iniRead: anIni key: self class name, ' placement' default: self placement). self allSubViews do: [:subView | subView name notNil ifTrue: [subView iniRead: anIni]]Obviously this approach requires that all views understand #iniRead: and #iniWrite:, so we add the following dummy methods to View: In View:
iniWrite: anIni "Save the receiver's state to anIni file. The default is to do nothing." iniRead: anIni "Restore the receiver's state from anIni file. The default is to do nothing."With these methods in place, no changes are needed for the application's read/write methods. Try it again. Start the browser, move the splitter, change the column widths, move the columns, then close the browser and reopen it. It should appear as you last left it. The final item in the bulleted list above is to save and restore the selected tab. To do this, add the following methods to CardContainer:
In CardContainer: iniWrite: anIni "Save the receiver's state to anIni file." super iniWrite: anIni. anIni writeKey: self name, ' selected tab' value: tabs selectionByIndex iniRead: anIni "Restore the receiver's state from anIni file." super iniRead: anIni. tabs selectionByIndex: (anIni readNumber: self name, ' selected tab' default: 1) ifAbsent: []Once again, try it. Start the browser, select a tab, close the browser and reopen it. It should appear as you last left it. If it didn't restore the last selected tab, that might be because you didn't give the card container a name, in which case edit the browser and name the card container view (e.g., 'cards').
The last step for now involves some reorganizing. For example, we've seen how to save and restore a listview's arrangement aspect, but what about other views -- their size, relative to a splitter, should be saved. To do this, we move the message send from ListView up to View. Since we're not interested in saving/restoring symbolic arrangements (e.g., #east), we add a test to see if the arrangement is a numeric. Anther change is to move the font-related messages from the application to ShellView, thus generalizing the automatic saving and restoring of the shellview's font. The final change is to add another bit of redirection and route the shellview read/write through the application's presenter (aShell) rather than calling it directly from the application.
In View: iniWrite: anIni "Save the receiver's state to anIni file." self arrangement understandsArithmetic ifTrue: [ anIni writeKey: self name,' arrangement' value: self arrangement] iniRead: anIni "Restore the receiver's state from anIni file." self arrangement understandsArithmetic ifTrue: [ self arrangement: (anIni readNumber: self name, ' arrangement' default: self arrangement)] In ShellView: iniWrite: anIni "Save the screen size and the receiver's state to anIni. Inform all named subviews to save their state to anIni." DesktopView current iniWrite: anIni. self placement iniWrite: anIni key: self class name, ' placement'. self font iniWrite: anIni key: self class name, ' font'. self allSubViews do: [:subView | subView name notNil ifTrue: [subView iniWrite:anIni]] iniRead: anIni "Restore the receiver's state. Restore the state of all subviews." self placement: (WINDOWPLACEMENT iniRead: anIni key: self class name, ' placement' default: self placement). self font: (Font iniRead: anIni key: self class name, ' font' default: self font). self allSubViews do: [:subView | subView name notNil ifTrue: [subView iniRead:anIni]] In Shell: iniWrite: anIni "Store the receiver's view's state." self view iniWrite: anIni iniRead: anIni "Restore the receiver's view state from anIni file." self view iniRead: anIniWith these changes in place, we need to add "super" message sends to some of the methods we added to descendants of View:
In ListView: iniWrite: anIni "Save the receiver's state to anIni file." super iniWrite: anIni. anIni writeKey: self name, ' column order' value: self columnOrder. anIni writeKey: self name, ' column widths' value: (self allColumns collect: [:col | col width]) asArray iniRead: anIni "Restore the receiver's column order and column widths from anIni." | columnOrder columnWidths | super iniRead: anIni. columnOrder := anIni readArray: self name, ' column order'. (columnOrder size = self columnOrder size) ifTrue: [ columnWidths := anIni readArray: self name, ' column widths'. columnOrder do: [:index | (self columnAtIndex: index) width: (columnWidths at: index)]. self columnOrder: columnOrder] In CardContainer: iniWrite: anIni "Save the receiver's state to anIni file." super iniWrite: anIni. anIni writeKey: self name, ' selected tab' value: tabs selectionByIndex iniRead: anIni "Restore the receiver's state from anIni file." super iniRead: anIni. tabs selectionByIndex: (anIni readNumber: self name, ' selected tab' default: 1) ifAbsent: []Finally, we revise the browser read/write methods:
In InifileBrowser: iniWrite: anIni "Sent by the receiver's view when it has been closed." super iniWrite: anIni. anIni writeKey: 'MRU filename' value: self filename. anIni writeKey: 'sections selection' value: sectionsPresenter selectionOrNil iniRead: anIni "Restore the receiver's state from anIni file." super iniRead: anIni. self model: (anIni readString: 'MRU filename'). sectionsPresenter selection: (anIni readString: 'sections selection') ifAbsent: [nil]Note on iniRead restoring a model: If you use Shell>>open:on: to start your application, i.e., passing the model as a parameter, you probably want that model to take precedence over the model that's in the ini file. In this case, in iniRead, first test if the model is nil before reading the model, i.e.,
self model isNil ifTrue: [self model: (anIni readString: 'MRU filename')].
In PersonalMoneyShell: onViewClosed "Sent by the receiver's view when it has been closed" super onViewClosed. self iniWrite: (Ini new section: self class name) onViewOpened "Received when the receiver's view has been connected." super onViewOpened. self iniRead: (Ini new section: self class name)To test this, start PersonalMoneyShell, move and resize the main window, resize the column widths, close the window and start it again. It should appear as you last left it.
Frankly, I was hoping to land at something even simpler, for example, a single unary message send that would set everything in motion. Towards that end, I implemented a new class method in Ini that's called from the application's onViewOpened method.
In Ini, class side: readWriteOn: aShell "Create an instance of the receiver, using aShell's class name as the instance's section. Setup observers on aShell to save and restore its state when aShell's view is opened (or already open) and closed." | ini | ini := self new section: aShell class name. aShell view isOpen ifTrue: [aShell iniRead: ini] ifFalse: [aShell when: #viewOpened send: #iniRead: to: aShell with: ini]. aShell when: #viewClosed send: #iniWrite: to: aShell with: iniThis method creates an ini instance, tells the shell to restore its state values (either right away or when the shell's view is opened) and sets up an observer for when the shellview is closed. Then you can simplify the application by deleting PersonalMoneyShell>>#onViewClosed and changing #onViewOpened as follows:
In PersonalMoneyShell: onViewOpened "Received when the receiver's view has been connected." super onViewOpened. Ini readWriteOn: selfThough not a single unary send, this is close enough to make me a happy camper. I can create an application, add the single message "Ini readWriteOn: self" to #onViewOpened, and the topview and subviews will be saved and restored.
If that were the end of it, I might remain a happy camper, but unfortunately, there's a major drawback that runs through all of this saving and restoring so far. The problem is that the framework doesn't fully support multiple views of a single presenter and you'll run into collisions. This is because data for each view is stored in the same ini section.
For example, suppose we have another view of InifileBrowser, one called 'Basic View'. (e.g., one that contains a listview, a splitter and a textedit. The listview is named 'sections' and shows the section names in the file. The textedit is named 'contents' and it displays the contents of the file.) If we were to run the existing default view and then run the new basic view, the basic view would initialize its state from the values of the default view. While that is fine for the presenter aspects, i.e., MRU filename and most recently selected section, the values for view aspects (e.g., shellview size, view arrangements, and listview column state) will undoubtedly be wrong.
Referring back to the earlier MVP diagram, one solution that comes to mind is to save data for each additional view in a separate section, e.g., '[InifileBrowser.Basic View]'. The key to making this work is to be able to identify one view instance from another and that turns out to be difficult. For example, from InifileBrowser>>#onViewOpened, which is where the action begins, the presenter knows it's an InifileBrowser. Its view is a ShellView, whether the view was generated from resource 'Default View' or 'Basic View'. Unfortunately, a view doesn't remember the resource that it was generated from, so we can't access the resource name to identify the view. Next we turn to View>>#name:, but it seems that you can only give subviews names, not ShellViews, so we can't even distinguish our topviews with unique names. Next I looked at using the topview's caption aspect, but this too has its problems, e.g., DocumentShell sets the caption to 'Untitled' by the time we get to access it in onViewOpened.
At this point I went back to look at the resource identifier and see if it could possibly be picked up somewhere, because this seems to be most logical and elegant identification scheme. I found that the resource identifer is accessed during the Shell's creation process and passed from one method to another, then dropped once the view has been created. So to make this work using a resource identifier would entail changing a system method, something I really didn't and don't like doing. But I did it, and here it is.
In Shell, class side: create: aResourceNameString "Answers an instance of the receiver created with no parent presenter and wired up to a view identified by the resource name aResourceNameString. The view must be a Shell and will be a child of the desktop" | newOne | newOne := self new. newOne propertyAt: #resourceNameString put: aResourceNameString. #lasAdded. newOne view: (self loadViewResource: aResourceNameString inContext: View desktop). ^newOne In Presenter, class side: create: aResourceNameString on: aModel "Answers an instance of the receiver with a view identified by aResourceNameString and connected to aModel" ^(self on: aModel) propertyAt: #resourceNameString put: aResourceNameString; "#lasAdded" createView: aResourceNameString; yourself.A message send, "propertyAt: #resourceNameString put: aResourceNameString", is added to two creation methods. I chose these two because they seem to cover most or all of the ways that I start up an application. Thanks to Andy Bower for mentioning properties (see DSDN and the Education Centre). All objects respond to three property messages: #propertyAt:put:, #propertyAt:, and #propertyAt:ifAbsent:. A property is intended to be used sparingly, to assign an aspect to an object where an instance variable doesn't exist. In this case, each creation method now assigns aResourceNameString to the #resourceNameString property of the shell instance. I consider this a benign addition to these system methods (i.e., no harmful side effects).
Now that the shell has this resourceNameString property, go back to Shell's iniRead: and iniWrite: methods. Shell's default behavior is to tell its view to save and restore view aspects, but the ini instance passed to the shellview represents the section of the ini file where we want to store presenter data. We need to change this so that the shellview is passed an ini instance that represents the view section of the ini file. First add a new instance creation method to Ini that returns an Ini instance that points to the view section of the ini file.
In Ini, class side: on: aShell "Answer an instance of the receiver, using aShell's class name and resourceNameString property as the instance's section." | subSection | subSection := aShell propertyAt: #resourceNameString ifAbsent: [nil]. ^self new section: aShell class name, ((subSection notNil and: [subSection ~= aShell view class defaultView]) ifTrue: ['.', subSection] ifFalse: [''])Note that if the shell does not have the #resourceNameString property or if the resource name is the view's default view, the returned Ini instance points to the presenter section of the file (e.g., 'InifileBrowser'). Otherwise, the returned Ini instance points to the fully qualified view section of the ini file (e.g., 'InifileBrowser.Basic view'). We use this instance creation method in the following changes to Shell's read/write methods:
In Shell: iniWrite: anIni "Store the receiver's view state to a new ini instance. Default is to not use anIni." self view iniWrite: (Ini on: self) iniRead: anIni "Restore the receiver's view state from a new Ini instance. Default is to not use anIni." self view iniRead: (Ini on: self)With these changes in place, the framework now supports multiple views. The next changes deal with automating the framework further, aiming for that single unary message. This part once again deals with changing a system method, so it's optional. Instead of initiating the read/write from the application's onViewOpened method, add the call to Shell>>onViewOpened.
In Shell: onViewOpened "Received when the receiver's view has been connected. Transfer the caption across to the view" super onViewOpened. "Transfer any icon across to the view" self updateIcon. "Flag the UI as being in need of validation" self invalidateUserInterface. "Generate and apply the caption" self updateCaption. "Setup auto save/restore of presenter and view state if enabled." self iniEnabled ifTrue: [Ini readWriteOn: self]. #lasAddedThis requires we add an #iniEnabled method to Shell.
In Shell: iniEnabled "Default is to do nothing, i.e., not enable auto readWrite of receiver's state." ^falseShell>>#onViewOpened checks to see if the application really does want to save and restore. If so, it sends #readWriteOn: to class Ini. Shell>>#iniEnabled establishes a default action for applications, namely not to save and restore. The nice thing about this mechanism is that to enable automatic save and restore (of view state) in any application, all you need to do is to override #iniEnabled, returning true. With this in place, you can further simplify InifileBrowser. Delete the onViewOpened method and add the following:
In InifileBrowser: iniEnabled "Enable autoReadWrite of receiver's state." ^true
For now what I've done is put everything except those methods in a package. If you want to implement the multiview functionality of the framework, you can add in the single line of code to both creation methods by hand, in which case if you uninstall the Ini package you need to hand delete those lines as well. Of course, why you'd want to uninstall the package after using it is unfathomable to me *s*. Similarly, if you want to use the single message send (iniEnable) approach, you need to add the line of code to Shell>>onViewOpened.
To recap, you can download and install the base package. To use the framework for single view shells, you can just add "Ini readWriteOn: self" to your application's onViewOpened method. Note that you can use this approach for multiple views if you don't mind the "collisions" or if you just want to save presenter state (and not view state).
If you want to use the framework for multiple view shells, you need to add the single message send to Shell>>create: and Presenter>>create:on: that sets the shell's #resourceNameString property.
If you want to use the simple unary method approach (#iniEnabled) to automate things, you need to add the single line of code to Shell>>#onViewOpened that calls "Ini readWriteOn: self".

If you've found this exercise helpful, let me know -- I welcome comments, criticism and feedback.
Louis Sumberg
Last updated April 2004