GF/ST Application Design & Decisionmaking
Designing a New Application
The best way to learn GF/ST is to build a simple application with it. Start simple, add complexity later. Do yourself a favor and try to factor your domain model classes out of your application. In other words, your ViewManager, ApplicationModel, or WbApplication class should act as the glue between user interface classes (like windows, GO’s, and their interfaces) and domain model classes that actually represent your problem domain. To emphasize the glue-like nature of these “interface” classes like ViewManager and ApplicationModel, we are simply going to use the generic class name GluePuppy. Now we can refer to the GFNetworkEditor as a subclass of GluePuppy in this document, and you’ll just have to translate mentally to the right class name in the Smalltalk you’re using!
The major demo applications like the Drawing Editor are incredibly useful, but we want to go through the entire thought process of building a brand new application. We’ve chosen as a learning vehicle something that software developers are already familiar with, a computer network.
The Scenario
We have a network of computers and a group of obnoxious network managers who are demanding a way to visualize the relative volume of traffic between them. We have the technology! The network managers can already see things in sorted lists, which are nice, but they want a way to get the big picture at a glance. In the great tradition of Smalltalk, we’ve taken on the job of building a prototype so we can give them a feel for the kinds of things that can be done.
We’ll limit ourselves in the prototype to simply being able to build the network interactively, assign weights to represent the traffic between nodes. In the real thing, this kind of information would be gathered from the network itself. We’ll use a simulation to show how we can easily help visualize the effect of network traffic. The simulation is a variation on the old N-body problem, where nodes repel one another if they are connected, and the higher the traffic weighting, the stronger the spring is connecting them.
The Problem Domain
Without offering any apologies to the methodologists in the crowd, we don’t need a committee and a huge white board to understand the problem domain for this little prototype. There is really only one domain model object - let’s call it GFNetworkNode. You may think we need a GFNetwork object also, and you’re right. For purposes of the prototype, though, we’re going to let the GluePuppy subclass invoke operations on the group of nodes (or GO’s, actually) that are held in the drawing. One might also argue that we need a GFNetworkConnection object, but we’re not going to worry about it right now.
One other consideration as to why you might want a GFNetwork object in your own application: what if you want to store the thing persistently? It’s not a good idea to store GluePuppy instances, since they might hold onto all kind of transient information, like Window handles. It would be a lot better to store GFNetwork objects. But, that is an exercise left to the reader.
The state information
A node is connected to other nodes - let’s hold onto them directly in an OrderedCollection of GFNetworkNodes. Each node needs to know what the traffic weighting is between it and the nodes it connects to. We could hold onto this information in another OrderedCollection, but since we might want to be able to locate and modify these weights arbitrarily as a user clicks around the screen, let’s use a Dictionary with keys of GFNetworkNodes and values of Numbers that represent the network traffic weight on a scale from 0 to 100. Nodes should also hold onto their name, a String.
The behavior
GFNetworkNodes will need the usual cast of accessing methods, at least if we want to provide public access to their state information. We also need to provide behavior for adding and removing connections between nodes, and for assigning and retrieving the weights. It is our decision that nodes will always know about all their connections; i.e., when I connect to you, I hold onto you in my collection of connections, and you will hold onto me in yours. The byproduct of this assumption is that nodes need to be smart about disconnecting themselves from other nodes. If you have an object that is holding onto a node (like a drawing, or a network), and you want to delete a node from it, you need to tell the node to disconnect itself from everything before removing it. Otherwise, you may be holding onto other nodes that hold onto the node you just deleted. Ain’t objects great? This little issue is one of the good reasons to use a GFNetwork object for all public protocol related to adding and removing nodes, rather than letting it be obfuscated in a GluePuppy subclass (even though we don’t do it in this prototype).
Here is what a brand new GFNetworkNode looks like:
Creating and Opening the Network Editor UI
GF/ST adds in just a few new requirements to the opening sequence you’ve always used for your application. You’ll still use old GluePuppy to glue things together, and you’ll still create views (or whatever your Smalltalk calls them) and position them in a container view of some kind. You can use WindowBuilder Pro. You’ll just have to hook your GFDrawingPane to an instance of GFDrawingInterface, which in turn holds onto a GFDrawing.
Opening Lines
Pick your own poison for your GluePuppy class. As we said before, this class is WbApplication. If you use the VAST Composition Editor, it would be AbtAppBldrView. We’ll call our new class GFNetworkEditor and stick with the style:
GFNetworkEditor open.
to start it up. You should feel free to use WindowBuilder Pro or the VAST Composition Editor with GF/ST. For GF/ST, the lowest common denominator is, unfortunately, none of the above. That means that the product has to work with them all, but can rely on none of them being present.
For convenience, we’re going to hold onto all the GFNetworkNode graphic objects in an instance variable called nodeGOs. For our prototype, we’ll use the collection of node GO’s as our network. It will also be convenient for us to hold onto the GFDrawingInterface (we’ll call it interface) that will be our main way to communicate with the drawing as a whole.
Note
In WindowBuilder Pro, you would use the preInitWindow method instead of the initialize method, typically.
In basic VAST image, you would implement the same steps in a mixture of an open method and a createShell method.
Now you need to write the open method. A generic open method in a basic VAST image, would look like this:
open
“Open a GFNetworkEditor”
interface := GFDrawingInterface newWithDrawing.
super open.
self revisit: nil
You will have to write the createViews and setUpMenus method yourself or use WindowBuilder Pro
We’re hoping that all of these details and platform specifics don’t scare you. There are really two simple things to remember:
1. Use a method like initialize, which is automatically called in your environment, to instantiate the GFDrawingInterface and hook it up to a drawing.
2. Hold onto the GFDrawingInterface in some convenient way, typically in an instance variable of your GluePuppy subclass, since you’ll be using it later.
Designing and Setting Up the Layout
Here is what we want it to look like, and you can refer to the code to see how it’s done:
We’ll use the ScrollBar at the bottom to let users adjust the weights between nodes when one is selected, and we’ll provide feedback on what the current value is using a simple StaticText pane. We’ll use the Balance, Step, and Halt buttons to start the simulation that updates the display to reflect the weights assigned to each connection, but we don’t have to worry about those things until we are able to create and connect nodes.
You’ll be creating a GFOwnDCDrawingPane and adding it as a subpane or child window in your createViews-type method. When you do so, you will create it for a GFDrawingInterface - the one we set up at the beginning in the initialize method. One way to do this is to use the protocol:
GFOwnDCDrawingPane forInterface: interface.
If you’re using WindowBuilder Pro, which automatically generates a createViews-type method which you don’t want to edit manually, you can assign the interface to the drawing pane in the initWindow method that is called before the window actually opens:
initWindow
(self paneAt: #drawingPane) setInterface: interface.
Remember, you won’t be talking to the GFOwnDCDrawingPane directly, but instead, you will use its interface. The “direct manipulation“ operations, like adding, connecting, and moving nodes have nothing to do with the static layout of the user interface that is defined in createViews. We’ll be hooking into events that are triggered by the GO’s as we create them, to be able to update our normal Smalltalk widgets, like ScrollBar and StaticText that are used in the GFNetworkEditor. In addition, we’ll need to update the state of our underlying domain model GFNetworkNode objects as the user establishes connections and moves the scroll bar.
Decisions Made in Building the Network Editor
By now, we’ve figured out how to open up the basic GluePuppy application and established what we want to do with it. So far, all we’ve done to complicate your life is made you hook up a GFDrawing with a GFDrawingInterface, and told you to be sure your GFOwnDCDrawingPane is connected to the GFDrawingInterface. Before moving on, we want to discuss some of the decisions that you will face when building an application like the Network Editor and provide some tips to make it easier.
Choosing GO’s
Let’s say we know we want nodes to look like rounded rectangles with text in them, as we saw in the picture. Do you know how to embed text in rounded rectangles yet? Probably not. Look through the names of the subclasses under GFGraphicObject. The easiest thing to do is to use the existing GFRoundedRectangleGO for now. You can worry about the text later. Without doing anything special, you’ll get the behavior and handles that are shown in the Drawing Editor. It’s probably not exactly what you want, but you can tailor it to meet your needs later.
Think before you GO out on your own
Do you need to create a new subclass for your graphic object? After all, this is a graphic object to display GFNetworkNodes. It should probably know what it is displaying, and we probably want all kinds of special behavior that we’re not going to get out of that old GFRoundedRectangleGO. The answer in almost all cases will be No. One reason is that the behavior of the network node GO you want is always invoked through a handle or a tool. And, you can combine GO’s into a group (a GFGroupGO) to make them look like almost anything you want. Check out the 3-D Figures demo; it was all done using the standard GO’s available in GF/ST. Finally, you can let GO’s hold onto your domain model objects (or any object) through the use of its metaObject.
When do you want a new kind of GO? This will be a design decision you need to make. For example, we felt that the Visual Inspector warranted whole new subclasses of GFGraphicObject, as well as specialized connection handles. On the other hand, we did not have to play around with GFTool at all.
Choosing Tools
The tool represents the mode of use of the drawing in your application. In the Network Editor prototype, we chose to use the GFSelectionTool always, and create the GO’s programmatically, as opposed to direct user mouse input. In other words, we’ll add new nodes as a result of a menu pick, not by selecting a GFCreationTool and rubber-banding.
When you create a GFDrawingInterface, it sets the default tool to be the GFSelectionTool. Graphic object classes will tell you what kind of creation tool is appropriate when you want to create a GO of that type. For example, if you want to switch to a creation mode for a GFRoundedRectangleGO, you would say:
interface selectTool: GFRoundedRectangleGO creationTool.
Be sure you provide a way to get back to a selection mode; for example:
interface selectTool: GFSelectionTool new.
The interface actually holds onto a palette of tools which is an instance of GFToolPalette. You can also use the palette to choose a category and a tool within that category. The Drawing Editor displays this palette using a floating window which lets you easily select a tool, but you can hook it up to a tool bar or menu item just as easily, since the GFToolPalette is not specific to any user interface. The preferable way to manipulate tool selections is to use the palette, although in simple cases like the Network Editor, where we are only using one tool, there is no need.
Adding a GO for a Network Node
By now, you’re beginning to get the idea that we were really serious about using the interface for most of your interaction with the components of a GF/ST application. Adding and deleting GO’s is no exception.
The Method
We decided to forego tool selection in this simple application by using “Add node” and “Delete node” menu selections. The result of choosing “Add node” in the menu is that our GFNetworkEditor will receive the message addNode. Let’s take a close look at the method (very close, considering the type size we have to use):
Line | Method Body |
1 | addNode |
2 | “Add a node to the display.” |
3 | | name node nodeGO | |
4 | name := Prompter prompt: ‘Enter node name’ default: ‘‘. |
5 | (name == nil or: [name isEmpty]) ifTrue: [^nil]. |
6 | node := GFNetworkNode new name: name. |
7 | nodeGO := self nodeGOFor: node. |
8 | nodeGO |
9 | when: #generateHandles |
10 | send: #handlesFor: |
11 | to: self |
12 | with: nodeGO; |
13 | when: #select send: #updateWeight to: self; |
14 | when: #deselect send: #updateWeight to: self; |
15 | origin: interface visibleRectangle center. |
16 | interface addGO: nodeGO. |
17 | nodeGOs add: nodeGO |
Creating a Network Node
In lines 4-6, we prompt for a name and create an instance of our domain model class, GFNetworkNode. GFNetworkNodes know nothing about how or if they are displayed. To do that job, we need a graphic object, which we create in the nodeGOFor: method seen in line 7. We’ll be discussing it more later on. For now, it could just return a new instance of GFRoundedRectangleGO.
Hooking to Events
Lines 8-12 show a single when:send:to:with: message that hooks into the generateHandles event that every GO triggers when it needs to display handles. By telling the GO that we will handle this event, we are saying that we will be the ones returning the handles for the nodeGO in our handlesFor: method. The nodeGO that triggers the event will pass itself along as the argument, so we can ask it any questions we want. Similarly, in lines 13 and 14, we are telling the GO to let us know to update the weight display whenever it is selected or deselected. We only want weight to show up when a connection is selected, not a node, so we need to know when a node is selected to be able to clear the display.
Positioning the GO
In line 15, we set the origin of the GO to be at the center of the visible area of the drawing. The drawing itself is, by default, the same size as the display device you’re on, but you may have scrolled up or down. The interface takes care of tracking where the visible area is relative to the drawing as a whole, so you can use it.
Making it Display
Up to this point in the method, we have drawn nothing on the screen, we have only created the GO, set up the events, and set up where it will be drawn. In line 17, we actually draw the GO on the screen by telling the interface to addGO:. As soon as it is on the screen, you get all the default behavior of the GO, so you can move it around and so on. However, because we hooked into the generateHandles event, we must have our own handlesFor: method. You can try it out without hooking into the generateHandles event, and you will get the default handles for the kind of GO you returned from the nodeGOFor: method.
Tracking GO’s
Last, but not least, line 18 shows how we are keeping track of each nodeGO we create in our own nodeGOs instance variable. Sure, the interface holds onto these things, but we just thought it would be clearer to hold them directly. In effect, this collection of GO’s is our network, since each GO knows what actual instance of GFNetworkNode it models. We set up that information in the nodeGOFor: method, our next topic.
Creating a GO in the Network Editor
We decided that we should use a GFGroupGO for the visual representation of our GFNetworkNode. As we’ve said before, by all means use the built-in GO’s like GFRectangleGO to get up and going if grouping GO’s might slow you down. On the other hand, a simple group GO consisting of a GFTextGO and a GFRoundedRectangleGO amounts to about three more lines of code than you would have to write for the GFRoundedRectangleGO alone.
Group or Composite?
Use a GFGroupGO if you will not be directly manipulating the objects in the group; otherwise, consider using a GFCompositeGO. Since we’ll just be setting the text on top of a rounded rectangle, we can use a group with no qualms. As previously discussed, GFGroupGO’s use up resources by using a Bitmap. Composite GO’s do not use a bitmap, because you need to be able to directly manipulate the individual GO’s inside of it. In a GFGroupGO, for example, there is only one menu - the one for the group - while in the composite, the each individual GO retains its menu. You can disable the use of cached bitmaps on GFGroupGO via the cacheFlag. Using the cacheFlag: method, you can disable the caching of bitmaps and still retain the behavior of a group. Setting the cacheFlag to false is slower for redraw, but uses fewer resources.
Creating the GFNetworkNode GO
Refer to the
How To’s section for details of the protocol for creating various kinds of graphic objects. GF/ST provides many class-specific instantiation methods that should meet your needs.
Here is the method we use to return a group GO for an instance of a GFNetworkNode:
Line | Method Body |
1 | nodeGoFor: aNode |
2 | “Private - Return a GO that is appropriate for aNode.” |
3 | | textGO rrGO h v nodeGO | |
4 | h := 3 * (Font systemFont stringWidth: aNode name) // 2. |
5 | v := 3 * Font systemFont height // 2. |
6 | textGO := GFTextGO text: aNode name. |
7 | textGO translateBy: (0@0 right: (h // 8)). |
8 | rrGO := GFRoundedRectangleGO rectangle: (0@0 extent: h@v) cornerEllipse: 5@5. |
9 | rrGO fillColor: Color yellow. |
10 | nodeGO := GFGroupGO graphicObjects: (Array with: textGO with: rrGO). |
11 | nodeGO metaObject: aNode. |
12 | ^nodeGO |
Lines 4 and 5 set up the height and width for the GO based on the name of aNode, the system font, and a factor of 3/2 times the string size. In line 6, we create a new instance of a GFTextGO with the node’s name in it. Once we have it, we want to push it to the right so it is centered. Be careful with point arithmetic to use methods like rightAndDown:. In lines 8 and 9, we create a new, yellow GFRoundedRectangleGO. Then, in line 10, we create a GFGroupGO and add the text and rounded rectangle to it. None of these operations has caused any display activity, which is handled via the interface in the addNode method of GFNetworkEditor.
Setting the metaObject
If you want a GO to hold onto another object, and in particular, if you want it to know about its domain model, you can use the metaObject for it. As in classic MVC-style programming, you should consider it to be perfectly proper for a graphic object to know about its underlying domain model, but you should avoid any knowledge in your domain model classes of how they are displayed. A side note for you MVC haters out there: loose coupling is the good part of MVC, duplication of view/controller protocols and rampant updates is the bad part. It’s not all bad! A side note for those of you who don’t know what MVC is: count your blessings. Just remember to factor your domain model classes out of your application, and never let them know how they are being displayed.
Line 11 shows how to set the metaObject for our new GFGroupGO we are using to display aNode. Similarly, if you have the GO and you want to find out what its node really is, just ask it:
node := aNodeGO metaObject.
The method ends in line 12 by returning the GO, of course.
Specializing Handles in the Network Editor
Whenever a GO needs handles (i.e., when you select it), it triggers the
#getHandles event. If you are handling that event, you need to return a collection of handles to be displayed. We saw in
Adding a GO for a Network Node that we set up the
#getHandles event by telling the
nodeGO:
nodeGO
when: #generateHandles
send: #handlesFor:
to: self
with: nodeGO
Now, we need to implement a handlesFor: method. There are a number of special things we want to accomplish in this method which we will be discussing in detail. Overall, however, the purpose of this method is to return the proper set of handles for the GO, with the proper positioning and behavior. This is our opportunity to override the default handle behavior for a GO. Here is what it looks like:
Line | Method Body |
1 | handlesFor: aNetworkNodeGO |
2 | “Private - Return the handles for aNetworkNodeGO.” |
3 | | connectionHandle aGO node | |
4 | node := aNetworkNodeGO metaObject. |
5 | connectionHandle := GFConnectionHandle |
6 | on: aNetworkNodeGO |
7 | at: #offCenter: |
8 | with: (self connectionLocationFor: aNetworkNodeGO). |
9 | connectionHandle setLocatorGenerationAction: [ :toGO :point |
10 | self locatorFor: toGO]. |
11 | connectionHandle setFindTargetAction: [ :int :point | |
12 | ((aGO := int graphicObjectAt: point) == nil or: [ aGO isConnectionGO]) |
13 | ifTrue: [nil] |
14 | ifFalse: [ |
15 | (aGO metaObject connections includes: node) |
16 | ifTrue: [nil] |
17 | ifFalse: [aGO]]]. |
18 | connectionHandle setTargetPositionAction: [ :toGO | toGO center + (self connectionLocationFor: toGO)]. |
19 | connectionHandle setConnectedAction: [ :conn :toGO | |
20 | node connectTo: toGO metaObject. |
21 | conn when: #select send: #updateWeightFor: to: self with: conn; |
22 | when: #deselect send: #updateWeightFor: to: self with: nil. |
23 | conn interface sendToBack: conn; |
24 | selections: (OrderedCollection with: toGO)]. |
25 | ^OrderedCollection with: connectionHandle |
Getting the node from the metaObject
When we created the GO, we set its metaObject to the specific GFNetworkNode it was displaying. Now we are going to use it, so we hold onto the node in a temporary variable in line 4.
Creating a GFConnectionHandle
The only handle we want our node graphic objects to have is a connection handle. Node GO’s are sized automatically when we create them, and we’re trying to keep the display clean. Perhaps we’ll use color at some later point to help network managers locate trouble spots, so we don’t want them to have access to a fill-color handle.
In lines 5-8, we create an instance of a GFConnectionHandle using the on:at:with: class-specific instantiation message. The arguments are the GO, a message selector that will return the appropriate point, #offCenter:, and the argument for that selector. We use our own connectionLocationFor: method simply to return a point that is 3/8 of the GO’s bounding box height.
Where Do Connection Lines Snap To?
The connection handle sits at the location you specify. When you hold down the left mouse button over it, GF/ST shows you a connecting line which jumps to the position defined by the
connection handle locator on a target GO. In this manner, the point your lines snap
to, defined by the locator, can be different than the place they come
from. In fact, the location they snap to as you drag a connection around can even be different than the place they end up when you let go (see
Where Are Connections Drawn To? below).
In line 9-10, we specify an action of some kind (i.e., a block or an EvaluableAction - anything that responds to the protocol of EvaluableAction) which, when evaluated, returns a GFLocator on a point. That point will be the place that lines snap to as you drag the cursor around while connecting nodes in the drawing. Note that the arguments which are passed to the locatorGenerationAction are the GO being connected to, and the mouse location.
The locatorFor: method referenced in line 9 says:
^GFLocator
on: aNetworkNodeGO
at: #offCenter:
with: (self connectionLocationFor: aNetworkNodeGO)
Refer to
GGLocator for a discussion of locators. Typically, the connection handle location, the point returned by the
locatorGenerationAction, and the point returned by the
targetPositionAction (discussed in
Where Are Connections Drawn To?) will all return the same point, but we didn’t want to limit your options.
Determining Valid Targets for Connection
By default, any object in the drawing can be connected to. In our Network Editor, we do not want users to be able to connect to the connecting lines between GO’s, and we do not want connections to be established more than once between the same GO’s. We can customize the connecting behavior for a GFConnectionHandle by setting its findTargetAction, as shown in lines 11-17.
Once again, the argument to the setFindTargetAction: method is an action of some kind (in this case a block) that is evaluated as you drag the mouse over GO’s in the drawing. If the result of evaluating the action is a GO, GF/ST will evaluate the locatorGenerationAction to determine where to connect to. If the result is nil, you will not be able to connect to that GO. The arguments supplied by GF/ST are the GFDrawingInterface and the location of the mouse. The logic in lines 12-17 asks the interface to determine what GO is located at the point, and it only returns the GO if it is not a “connection” GO and if the node itself is not already connected to the target node.
Where Are Connections Drawn To?
In line 18, we set the targetPositionAction for the handle. Typically, the target position is the same as the location as is specified by the locator used in the locatorGenerationAction, but it does not have to be. You would make the targetPositionAction return a different point if you want the connecting lines to connect to a different point than they snap to during the connection process.
The targetPositionAction, when evaluated with the targetGO as the argument, must return a Point. Note that the locatorGenerationAction returns a GFLocator.
What To Do After Making a Connection
When you connect two GO’s, GF/ST evaluates the connectedAction for the handle. It passes the connecting line itself and the target GO (the one you’re connecting to) as arguments. The connectedAction is set in lines 19-24.
When we connect one GO representing a node to another, we need to change the state of the underlying domain model nodes, as shown in line 20. While we’re at, if we need to take any action when the user clicks on the connecting lines themselves, we might as well set up the events at the time the connection is made. We want update the display of connection “weight” in the GFNetworkEditor whenever we select or deselect a connecting line, as shown in lines 21-22. It makes the display look nicer if we always push the connecting line to the back, behind all other GO’s, and this is done in line 23. Finally, users felt it made connecting nodes easier if we select the one we just connected to each time, as shown in line 24.
Don’t Forget to Return the Handles
When we’re all done, we better return a collection containing the handles, as shown in line 25. In this case, we only have the one handle, a GFConnectionHandle. We’ve done all the work in this method to make it display and behave the way we want.
Removing Nodes in the Network
It is simple to remove GO’s from a drawing using the removeGO: method of the GFDrawingInterface. However, our job is more complicated in the Network Editor. We are not just removing it from the display, but we need to unhook the connections in the display and the underlying domain model.
Referring to the deleteNode method in the GFNetworkEditor, you can see how the GO’s are used to determine what domain model objects are affected by a deletion of multiple nodes on the screen.
Line | Method Body |
1 | deleteNode |
2 | “Delete any selections that are nodes.” |
3 | | node | |
4 | interface selections copy do: [ :nodeGO | |
5 | nodeGO isConnectionGO ifFalse: [ |
6 | (interface connectorsUsing: nodeGO) do: [ :conn | interface removeGO: conn]. |
7 | interface removeGO: nodeGO. |
8 | nodeGOs remove: nodeGO. |
9 | nodeGO metaObject disconnectAll. |
10 | ]. |
11 | ] |
Determining What GO’s are Selected
In line 4, we loop over all the GO’s which are selected in the drawing. The interface returns a collection of the selected GO’s when we send it the message selections. We need to make a copy of that collection because we will be removing GO’s as we iterate over it. As a general rule, never modify a collection as you iterate over it - make a copy of it, or find some other way to accomplish your objective.
In line 5, we filter-out all of the connection GO’s; i.e., we are writing a method to remove nodes, not connections. When we remove a node, we will take care of any affected connections.
Removing Connections
The GFDrawingInterface can tell us what connection GO’s are connected to another GO via the connectorsUsing: method, as shown in line 6. We then tell the interface to remove the connection GO’s using removeGO:.
Removing a GO and Cleaning Up
In line 7, we remove the node GO from the interface. We are tracking these GO’s in our instance variable, nodeGOs, so we need to remove it from there, too, as shown in line 8. We decided when we built the domain model that GFNetworkNodes should know how to disconnect themselves from the nodes they connect to, and this is accomplished in line 9. Once again, we can send a message directly to the GFNetworkNode using the metaObject of the GO.