ToolDesignDoc.Tioga
Last Edited by: John Maxwell on October 7, 1983 10:49 am
Last Edited by: Michael Plass on May 18, 1984 9:03:51 am PDT
Last Edited by: Subhana, May 29, 1984 11:06:13 am PDT
TOOL DESIGN
TOOL DESIGN
CEDAR 5.2 — FOR INTERNAL XEROX USE ONLY
CEDAR 5.2 — FOR INTERNAL XEROX USE ONLY
Issues In Viewer Tool Design
Release: [Indigo]<Cedar5.2>Documentation>ToolDesignDoc.tioga
© Copyright 1984, 1987 Xerox Corporation. All rights reserved.
Abstract: Designing good tools in the Viewers world is a tricky business. It shouldn't be tricky, but it is. This report talks about some of the issues involved, and explains why you might do things one way instead of another. It is the result of several people's experience in designing really solid tools. The conclusions that one comes to are not always obvious, so it is worthwhile reading this report carefully. Hopefully the report will stimulate ideas about how to make tools easier to design.
Attributes: user interface, viewers, sub-classing, menus
XEROX Xerox Corporation
Palo Alto Research Center
3333 Coyote Hill Road
Palo Alto, California 94304
For Internal Xerox Use Only
Isolation of user-interface issues.
In Cedar here are six potential ways to invoke a particular function in a package: through a TIP table, through a menu item, through a button item, through a Viewer Class procedure, through a Commander.CommandProc, or directly through a Mesa interface. Each of these has slightly different semantics and slightly different syntax. This often makes it difficult to treat input from the user in a uniform way, so some care is necessary on the part of the implementor.
The philosophy behind TIP tables is to have an external representation for mapping user actions into functions. A TIP table is not part of a package's code; it is a human-readable table found on the disk. If the user is unhappy with the package's interface, he can change it by modifying the TIP table. Menus and buttons do not currently have this feature.
Another feature of the TIP scheme is the centralization of dispatching. All of the input derived from a TIP table is funneled through one procedure: the class's NotifyProc. From there, the input is dispatched to the appropriate procedure. Centralizing input like this allowed Tioga to have an event notification scheme, where clients outside of Tioga could asked to be notified whenever a particular event occurred. One possibility for the future is to make this event notification scheme available to every client of Viewers, not just Tioga.
The right way to separate the user interface from a package's functionality is to first write the package for clients, and then design the interface for users. (A client is a program, a user is a human being.) Do not assume that users will be the only ones using your program. Eventually, someone will want to write a program that does automatically what he used to do manually. You might as well design your system now with that in mind.
Once you have a package written that clients can use, then you can design an interface for the user. Think of all of the functions you want the user to be able to do. Figure out a good name for each function. Then write a NotifyProc which dispatches these names (as
ATOM's) into calls on your package. The NotifyProc is also the place to bind the parameters for the functions. For instance, an arm of a
SELECT statement in the NotifyProc might look like this:
$Rename => Tool.Rename[viewer: self, name: ViewerTools.GetSelectionContents[]];
Where the new name is given by having the user select it somewhere.
Finally, you should design the interface that the user will see. Decide the names of the menu items. Decide whether left-clicking should have a different meaning from right-clicking. Then write the menuprocs and buttonprocs as well-isolated section of code, so changes will be easy.
Conventions for persistent viewers.
There are several applications now that would like to be able to simulate viewers that persist over a rollback or boot. Unfortunately, they can't do this without some cooperation from the implementing package. This section talks explains what these applications want to do, what they need, and what the implementor must do in order to make it all work.
There are three applications that would like to be able to simulate persistent viewers: desktops, whiteboards, and the data base. All three applications store viewers in data structures that persist beyond a rollback or boot. The desktop stores configurations of viewers in a file on the disk, whiteboards store viewers on whiteboards in a data base, and the data base stores viewers in a data base file on a remote server. In order to be able to use the viewers they have stored, they must have a way of re-creating them from scratch. Calling their implementors doesn't work, because you may not know who implemented a particular viewer. The only way that can work is to establish some sort of convention with the implementors about how viewers get created.
The convention that I propose is that calling ViewerOps.CreateViewer with a viewer class and instance name would return a correctly initialized viewer. For this to work, implementors of tools would have to abide by the following rules:
1) Each tool has to have its own class. If a there are several tools that belong to the same class then there is no way to distinguish between the tools later on. For instance, a large number of tools currently belong to the Container class. Calling ViewerOps.CreateViewer[$Container, "Watch"] may create a container with the name "Watch", but it won't create a Watch tool. ViewerOps.CreateViewer[$Watch, "Watch"] has to be implemented.
2) Each new instance must be initialized by the InitProc, and nowhere else. It must be the InitProc that puts the menus up and adds the buttons and sub-viewers. If any part of the initialization is done outside of the InitProc, then the special applications will get only partially initialized Viewers.
3) The name and file of a viewer must uniquely identify what instance is being created. The only variable parameters that can be passed to ViewerOps.CreateViewer are the name and backing file. Classes that don't need a backing file can use the name passed in the backing file to help identify the instance. For example, Walnut message windows might store the grapevine ID as the backing file and use that information to determine which message is wanted.
A simple kind of sub-classing may be implemented by copying the parent class record, and then altering procedures that must be different. If the implementor wants to substitute his procedure for one of the super-class's procedures, he need only replace it in the class record. If he wants to use his procedure sometimes and the super-class's procedure sometimes, then he can replace the super-class's procedure with his own and then make an explicit call within his procedure to the super-class's procedure. An example of this is:
IF self.class.parent.notify # NIL THEN self.class.parent.notify[self, input];
(This scheme is similar to the way Smalltalk does inheritance.) Note that the InitProc should almost always be handled this way.
One caveat: If you define a private NotifyProc for your sub-class, make the ENDCASE of the SELECT statement call the super-class's NotifyProc. Otherwise, input that was destined for the super-class will get dropped on the floor.
Issues in serialization.
There are three ways of serializing actions in the Viewers world: using the notifier, MBQueues, or monitor locks. Each way has its use; none of them is superfluous. However, knowing which of them to use can sometimes be tricky. Hopefully this discussion will help.
Using the notifier.
The notifier is the first line of defense. It is a single process that handles mouse actions and keyboard strokes. It is responsible for queueing up user actions, determining which viewer they belong to, and then passing them to the viewer via its NotifyProc. The notifier waits until the NotifyProc returns before processing the next user action. This means that all user input starts out serialized.
A simple way for an application to keep its actions serialized would be to hang on to the notifier process until its work is done. That is, when its NotifyProc gets called from the notifier, it doesn't return until all of the work is finished. However, this would prevent the user from doing anything else while the application is running. Good citizenship requires that you release the notifier process as soon as you can so that the user can do other things.
One way to release the notifier process is to fork another process to do the work. Buttons and menus automatically fork processes before calling the NotifyProc (unless the implementor tells them to do otherwise). However, once a process has been forked, all serialization has been lost. While the forked process is running, the notifier may fork more processes to do the same thing. Something else will have to be done to coordinate these processes further on down.
Using MBQueues.
A better way to release the notifier process is to put the user input on an MBQueue. MBQueue stands for Mouse Button Queue. An MBQueue is a queue of user actions: button clicks, menu clicks, or other unspecified actions. The MBQueue interface allows you to replace the standard buttons and menu entries with special buttons and menu entries. These special buttons and menu entries will put its user actions on a queue. The notifier is held until the action is on the queue, so the queue is guaranteed to have its actions in the same order that the user invoked them. Usually an application will have one application-wide queue and a single process to handle it. The process will pull things off of the queue one at a time, finishing one action before going onto the next. This means that the application's input remains serialized and the user can go on to do other things.
For the most part, MBQueues are easy to use. The only time they become tricky is when you are dealing with parameters. Suppose you have a button that does something to a file, and the name of the file is kept in a nearby text viewer. If you use MBQueues in the obvious way, you may get an inconsistent name from that text viewer. Consider: The button click gets put on an MBQueue. Things are slow, so it justs sits there for a while. In the meantime the user changes the name of the file in the text viewer. Finally, the button gets taken off of the MBQueue and invoked. Unfortunately, it gets the wrong file name out of the text viewer. This is an example of incorrect binding.
The way to fix this is to bind the name of the file to the button before putting it onto the MBQueue. When the notifier calls the button, the button should extract the name from the text viewer while it has the world frozen. It should then put itself on the queue with the name of the file as its clientData. Later, when it gets invoked, it will execute with the correct parameter. This technique can be generalized for any type of parameterized action.
This example points out a general principle: the notifier is the only means of making multiple user actions atomic. Any time you want to gather several parameters from the user atomically, you must do it while holding onto the notifier process. If you don't hold onto the notifier process, then you run the risk of having the user change one of the parameters before you read them all. This is true no matter what other means you plan to use to serialize the atomic actions.
Using monitor locks.
The notifier and MBQueues are all you need if the application is used only by the user. However, if you ever plan to let people write programs that use your application, then the notifier and MBQueues aren't enough. Somehow client calls will have to be serialized with input from the user.
A kludgy way around this problem is to force clients to act like users. That is, they have to simulate input from the user by putting user actions into Inscript or putting actions on the application's MBQueue. This is not recommended. The right way to handle this problem is to write your application with multiple clients in mind. Then the user is just a special client.
I believe that people should write their applications this way, even if they think of their application as primarily being for the user. Somewhere down the road someone is going to want to write a program to automate what he has been doing manually. You might as well plan for it now.
There are many ideas about how to write applications that work in the face of multiple clients. A whole paper could be written on that topic alone. I am not going to try to do that here. Instead, I will just touch briefly on the major schemes that have been used:
Entry procedures. Require clients to call through a few well-known procedures. Make those procedures entry procedures which all use the same monitor lock. (This allows at most one client into the application at a time, but it is easy to implement.)
Queues. Funnel all of the client requests throught a queue. Have a single process at the other end that handles the requests one at a time. (This allows at most one client into the application at a time, like entry procedures. Its advantage is that requests are handled by the application in the same order that the clients give them. This scheme takes a little more work than entry procedures.)
Stateless applications. Write the application in such a way that the only mutable values used are kept in local frames. Treat procedure parameters as immutable. Don't use global values unless they are immutable. Store your temporary variables only in local frames, never in a global frame. (This scheme doesn't require any locks at all and it allows lots of parallelism. However, the constraints are hard to meet.)
Object monitor locks. Put monitor locks on each instance of the data. Make each procedure that reads or writes the data an entry procedure (object style). Eliminate any use of global data. (This scheme has lots of parallelism, but it is takes a lot of work to implement.)
Process re-entrant locks. Create your own brand of object monitor locks. Distinguish between read and write locks. Allow processes to re-acquire the same lock as many times as they want. Define "CallUnderLock" procedures that take the data and a client procedure and lock the data before calling the client procedure. (This is the most functional scheme, but it is the hardest to implement correctly. Viewers and Tioga both use this scheme. Talk to a wizard before attempting to implement it.)
If you use one of these schemes to serialize client requests, you won't have to bother with MBQueues to serialize user input. User input will be serialized just like all of the other client requests. However, you still may have to use the notifier to make some actions atomic. If you do, be sure to fork your calls explicitly to the application. Otherwise you will lock up the notifier while your application is running. (Normally Buttons and Menus fork a process for you. If they don't, then you will have to.)
PaintProcs and you.
It is often tempting to do some of your data-structure maintainence inside of the paintProc, and if you look at existing implementations, you will find many instances where this is done. However, I recommend that you avoid this practice, and use the paintProc only for painting. Here are some reasons why this is a good idea:
-- Debugging code inside of paintProcs is often a clumsy. (There are some kludges in Viewers to make it tolerable, but all in all it is better to have your program break outside of the paintProcs.)
If you never need to debug your code, you may disregard this advice.
-- If everyone played the game this way, Viewers could abort painting requests (e.g., when a still-painting viewer is closed) simply by asking the graphics package to raise an error the next time an imaging operation is requested.
-- It makes for a better separation of issues - paintProcs are for painting (and nothing else).
Multiple instances of a tool.
It is a bad idea to design your tool so that there can be at most one instance of the tool. Even if you don't think anybody would ever want more than one, somebody will. They will want to be able to have different instances with different parameters so they can switch back and forth easily. It is better to let the user have the freedom to do what he wants.
There are several ramifications to the decision to allow multiple instances of a tool. The first is that you cannot store data in the global frame, you must associate it with its viewer. The best way to do this is to attach it to the viewer's property list. (See ViewerOps.AddProp and FetchProp. I generally define procedures that add and fetch the data from the viewer without my having to remember what the property name was). The second is that serialization may be more difficult. If you want maximum parallelism, you will have to use object monitors instead of regular monitors. Object monitors are harder to use than regular monitors.
The last ramification is that care must be taken or you will end up with circular data structures. Circular data structures are a problem because they aren't collected by the normal garbage collector. They can only be collected by the trace-and-sweep garbage collector. They may show up if you have a main data structure associated with the whole viewer, plus auxilary data structures for each button. If a button wants to go from the auxilary data structure to the main data structure, the auxilary data structure might have a pointer to the main data structure. If the main data structure wants to be able to enumerate the auxilary data structures, it may have pointers to them. The result is circularities. These circularities can be avoided if the button gets the main data structure from its parent rather than having a pointer in the auxilary data structure.
Dealing with control panels that appear and disappear.
Sometimes you want a tool that has different control panels up depending on what the user is trying to do. The easiest way to do this is to have all of the control panels available and just move the appropriate one into view. This can be accomplished by creating all of the sub-viewers, and then moving the ones you don't want to see way out in hyper-space. (Scott had aesthetic objections to this, but I don't see any practical problems with it.) Sub-viewers can be moved around with ViewerOps.MoveViewer.
To make things easier, put your control panel inside an invisible nested container. Then all you have to do is move the container around, and all of the other viewers will follow automatically. The nested container will be invisible if you create it with border: FALSE and scrollable: FALSE.
Known problems.
Using other fonts.
The viewers package allows you to use fonts other that the Tioga font, but it doesn't work very well. It is hard to get the base line of text boxes and buttons to line up. The Tioga font has been hand-twiddled to make viewers look nice. If you use another font, it doesn't look very good. You should probably stick to the Tioga font until this gets fixed.
GraphicsOps.DrawBitmap and MoveDeviceRectangle.
A lot of people have trouble making these do what they want them to. You have to read the comments very carefully to figure out how they use their parameters. The confusion stems from the fact that these procedures work in the device's coordinate system, which is upside down from the screen's coordinate system. The procedures assume that the origin is in the upper left hand corner, with positive y going down. The screen sets the origin in the lower left hand corner, with positive y going up. Here is how you might get around this problem when drawing a bitmap for an icon:
Graphics.SetCP[self, 0, 64]; -- move to upper left corner
GraphicsOps.DrawBitmap[self, bitmap, 64, 64]; -- draw down from upper left corner
A Summary of Design Rules.
1) Design your tool for clients first. After your package is ready to handle clients, write a thin veneer over the package for the user. Set it up so that the user can tailor the interface the way he wants.
2) Handle serialization within the application rather than within the interface. Don't depend on input coming from the user alone. Assume that you will have other clients some day.
3) Follow the protocol for creating new instances. Let each tool have its own class. Make sure that all of the initialization goes on in the InitProc and nowhere else. Make sure that the name and backing file for the viewer uniquely identify what instance is being created.
4) Plan for multiple instances of the tool. Don't store data in a global frame; put it on the viewer's property list. Use object monitor locks or depend on the application to handle serialization.
5) Set fork: FALSE on Buttons or Menu entries that get parameters from other viewers or sub-viewers. Otherwise the parameters may change before they get accessed. Be sure to release the notifier after you have the parameters by forking a new procedure or putting your input on an MBQueue.
6) If you use paint: FALSE, be sure to write-lock the viewer, otherwise you will get painting glitches. Invoke ViewerLocks.CallUnderWriteLock with a procedure that does all of the painting.