Graphic Objects

A graphic interface bypasses the verbal and appeals directly to the visual


May 07, 2007
URL:http://drdobbs.com/graphic-objects/199204122

Paul Graham is an essayist, programmer, and programming language designer. He can be contacted at www.paulgraham.com/. This classic article first appeared in AI Expert, October 1988.


A user interface is an unusual sort of program because its success depends on emotional appeal. Emotional appeal doesn't ordinarily enter into our judgments of software. We judge a compiler according to more objective considerations, such as its speed and robustness and the speed and robustness of the code it generates. With interfaces, the rules are different.

The user interface is often the least sophisticated element of a program but the one users most care about. For the programmer, this means a relatively small amount of effort can make a program more appealing.

The biggest payoff, as software developers are discovering, is in graphics. A graphic interface by-passes the verbal and appeals directly to the visual. And anyone who has seen a video game knows how much more quickly visual information is assimilated. Who could play a real-time video game in which the state of the game was displayed as a screen of text?

This article discusses how to write graphic interfaces for LISP programs. It includes most of the code necessary to attach graphic objects -- dynamically updated graphic representations -- to LISP data structures. The code can be used to make a wide variety of programs more appealing to the front end of nearly any LISP program in a matter of hours.

This code runs under Common LISP with a very simple window system. If your LISP doesn't support windows, the code will work if you simply eliminate all references to them. It could easily be adapted to run under some other other lexically scoped LISP (such as Scheme), but it depends too heavily on lexical scoping to run under dynamically scoped dialects.

The Aim

This article will discuss graphic interfaces with an object-oriented flavor. To say that the things we're going to make will be objects is to say that:

A concrete example will make all this clearer. Using the code given here, we will be able to define a type of graphic object called a "dial". We will then be able to make instances of dials as we need them, attaching them to LISP objects simply by saying attach, dial, and where we want the dial" attached: Once we have attached a dial to something, the dial will henceforth display its value. As the value stored in the LISP object changes, the needle of the dial will move automatically.

Tiny Database Program

Before we can attach graphic objects, we need something to attach them to. So we begin by defining a tiny database program. A database will simply be a hash table, created by calling make-db:

(defun make-db (&optional (size 100))
    (make-hash-table :size size))

Hash tables are built in to Common LISP. For our purposes, hash tables are nothing more than a fast way of doing what one does with property lists. If you want to implement this code in a dialect that doesn't support hash tables, you can use property lists, and the only difference will be in speed.

In these databases we're going to store two-part structures defined as follows:

(defstruct db-entry
    value
    attachments)

The value field will hold the LISP object we want to store in the database, and the attachments field will contain a list of functions that have to be invoked when the value field is altered. To attach a graphic object to a database entry, we will store a function referring to that object in the entry's attachments field so that when the entry is changed the graphic object will automatically be notified. Four functions for manipulating databases are shown in Listing One.

(defun db-update (db key val)
   (let ((entry (gethash key db)))
      (if entry
         (progn
           (pre-update entry)
           (setf (db-entry-value entry) val)
           (post-update entry))
         (setf (gethash key db)
             (make-db-entry : value val))))

val)

(defun db-delete (db key)
   (let ((entry (gethash key db)))
      (when entry
        (delete-attachments entry)
        (remhash key db))))

(defun db-Query (db key)
   (let ((entry (gethash key db)))
      (and entry (db-entry-value entry))))

(defun db-push (db key val)
   (db-update db key (cons val (db-query db key))))

Listing One: Tiny database program.

The function db-uPdate adds or modifies a database entry. Note that if an update is modifying an existing entry, the change to the value field is sandwiched between calls to pre-update and post-update. These functions will spur graphic objects into action (we'll discuss them later).

The second function, db-delete, deletes an entry. The call to delete-attachments deletes any attached objects (this function will also be discussed shortly). The remaining two functions are completely straightforward.

Here we'll make a database to use in experiments later on:

> (setq our-db (make-db))
#(Hash-Table 88BBCB>
> (db-update our-db 'temperature 95)
95
> (db-query our-db 'temperature)
95

Demons

The next step is to decide what form attachments should take. In the parlance of object-oriented programming, a demon is something that keeps watch over an object and does something whenever it changes. The things we are going to store in the attachments of database entries will be demons, defined thus:

(defstruct demon
   (pre-action #'nilfn)
   (post-action #'nilfn)
   (self-destruct #'nilfn))
   window
(defun nilfn (&rest args)
   (ignore args))

If a demon is the sort that runs a graphic object, its window field will contain the window in which the graphic object resides. Demons do not necessarily have to run graphic objects -- if one doesn't, its window field will simply be nil.

The three fields of a demon structure will contain functions (more precisely, closures) that, when called, perform certain actions. Note that the first three functions in Listing Two all have the same form. The first one, pre-update, is the function called by db-update just before it overwrites a database entry. The effect of calling pre-uPdate for an entry is to call the pre-action function of each of its demons. The functions post-update and delete-attachments behave analogously.

(defun pre-update (entry)
  (dolist (d (db-entry-attachments entry))
    (funcall (demon-pre-action d))))

(defun post-update (entry)
  (dolist (d (db-entry-attachments entry))
    (funcall (demon-post-action d))))

(defun delete-update (entry)
  (dolist (d (db-entry-attachments entry))
    (funcall (demon-self-destruct d))))

(defun add-demon (db key access-fn pre-fn post-fn)
  (let ((entry (gethash key db))
     (d (make-demon)))
  (setf (demon-pre-action d)
     #'(lambda ()
       (funcall pre-fn
           (funcall access-fn
               (db-entry-value entry)))))

   (setf (demon-post-action d)
    #'(lambda()
     (funcall post-fn
           (funcall access-fn
               (db-entry-value entry)))))

   (setf (demon-self-destruct d)
    #'(lambda()
      (delete d(db-entry-attachments entry))))
   (push d (db-entry-attachments entry))
   d))

Listing Two: Code supporting demons.

Listing Three shows how demons are used. We'll attach a demon to the temperature entry of our-db, (in accordance with the greenhouse effect) sets the temp-in-50-years entry to be 10 degrees' higher. There is no need for this end after only one cycle. If we wanted, we could attach further demons to temp-in-50-years, which would update sea-level-in-50-years, and so on.

> (setq d
   (let ((entry (gethas 'temperature our-db)))
   (make-demon
     :post-action
     #'(lambda ()
       (db-update
         our-db
         'temp-in-50-years
         (+ (db-entry-value entry)
            10))))))
#S(DEMON...)
>  (push d(db-entry-attachments
           (gethash 'temperature our-db)))
(#S(DEMON...))
>  (db-update our-db 'tempature 98)
98
>  (db-query out-db 'temp-in-50-years)
108

Listing Three: Example of what we can do with demons.

The remaining function in Listing Two, add-demon, does more or less what we did in Listing Three. This function looks a bit imposing, but it's long only because it's rather repetitive.

One thing that might seem confusing is the purpose of the parameter access-fn. We need it because the value field of the entry which we attach a demon might contain a structure instead of a simple atom, as temperature did. In that case we may want the demon to be attached to a subpart of the structure. The access-fn specifies where the demon should attached to within the entry's value field. If we had used add-demon to attach a demon to the temperature entry as the access-fn since we are looking at the value field itself. If we attaching a demon to an entry whose value field contained a list and wanted the demon to react specifically to the car of the list, we'd see #'car as the access-fn.

Note that add-demon fills new demon's fields with closures, not merely functions. The pre-action and post-action functions are closed over the entry to they are attached, and the self-destruct function is closed over the demon itself.

Simple Graphic Objects

Now we can make databases and attach demons to entries them. The last remaining to define what a graphic object is and add code for making demons that instantiate them.

We will define a graphic type, or gob, to have the following form:

(defstruct gob
  unchanging
  changing)

Please note that a gob is an archetypal graphic object. The dial gob will not be attached to any database field, for example, but will be the source from which particular dials are instantiated.

A has two fields for the sake of efficiency. A graphic object might be be a very complex picture, only a few of parts of which ever change. In a clock, for example, only the hands have to be re-drawn. The unchanging field of a gob contains a function that draws the immutable parts of a graphic object (the clockface) and the changing field contains a function draws the moving parts (the hands). When you have a screen full of complicated graphic objects, the time required to update them all becomes a serious issue.

We will use our database utilities to keep track of the gobs themselves:

(setq gobs (make-db))

When we need to find the dial gob, we can look it up in this database under the key dial.

The gobs given as examples here use a very limited subset of graphics commands. Any LISP system with graphics will offer something like them. The commands are:

(wmake x y width height title)
(point window x y)
(line window x1 y1 x2 y2)
(circle window x y radius)
 

The first function returns new windows and the remaining functions draw on them. They all do just what they appear to do, except in one respect. When the drawing functions put pixels on a window, they exclusive-or (xor) them with the pixels already there. Almost all window systems allow this.

The advantage of xoring is that when you xor the same picture twice, the screen is returned to its initial state. That's why, when attach-gob calls add-demon, it gives the same function as both the pre-fn and post-fn. The disadvantage of xoring is that when two black lines intersect, the intersection is white -- but this is a small price to pay for the convenience it brings.

Listing Four contains all the code needed to define and instantiate gobs. The function defgob is very straightforward -- it just makes a gob and stores it away.

(defun defgob (name unchanging changing)
   (db-update gobs name
   (make-gob : unchanging unchanging
             : changing changing))
   name)

(defun idfn (X) x)

(defun attach-gob (db key gobname window
                    &optional (scale 1)
                   (access-fn #'idfn))
   (let* ((gob (db-query gobs gobname))
      (valnow (funcall access-fn (db-query db key)))
      (dem (add-demon db key
          access-fn
          #'(lambda (val)
               (funcall (gob-changing gob)
               val window scale))
          #'(lambda (val)
               (funcall (gob-changing gob)
               val window scale)))))
    (funcall (gob-unchanging gob)
          valnow window scale)
    (funcall (gob-changing gob)
    valnow window scale)
    dem))

(defun attach-gobs (triples window &optional (scale 1))
   (dolist (trip triples)
   (apply #'(lambda (db key gobname)
            (attach-gob db key gobname window scale))
          trip)))

Listing Four: Definition and instantiation of gobs .

The following is just about the simplest possible gob:

(defgob 'simple
   #'nilfn
   #'(lambda (val window scale)
     (line window
        0 50
     (* val scale) 50)))

A simple gob represents the value to which it is attached as a horizontal line. It doesn't have any unchanging parts, and the only changing part is the line.

All gob functions are assumed by the demons that call them to have the three parameters shown in the definition of simple; the value being represented, the window in which the instantiation resides, and its scale.

The last remaining function needed to link all this code together is attach-gob, which instantiates one of the gob types (for example, simple) in a demon attached to some database entry. This is what it turns out to mean to "instantiate a gob": attach-gob makes a demon whose actions call the gob's changing function. Note that, as in our previous demons, these are closures, closed over the window in which the instance is to appear, and its scale.

Having created the demon, attach-gob starts the new graphic object off by drawing both its unchanging and changing parts. Finally, the newly created demon is returned. The last function, attach-gobs, makes it convenient to instantiate several gobs in a single window.

Now, with a single command, we can attach a graphic object to our pet database entry. First we make a window, then all we need do is call attach-gob with the database reference, the gob type, and the window (Listing Five).

> (setq our-window (wmake a a 100 100 "Our Window"))
#<Window. ..}
) (attach-gob our-db 'temperature 'simple our-window)
#S(DEMON. ..)

Listing Five: Attaching a graphic object.

At this point the window should contain a line representing the fact that the temperature is 98. Having attached the graphic object, we can forget that it exists. When we change the temperature to something more pleasant, the graphic object gets updated automatically:

> (db-update our-db 'temperature 72)
72

This may seem like a lot of work just to represent a scalar value so simply. In fact, the code we have discussed is much more general than that. We can use it to attach any sort of graphic demons, not just graphic ones. When we do want to make new graphic objects, we can define new graphic objects, and even new types of graphic objects, on-the-fly. We can attach as many as we want to a database entry or to subparts of a database entry. We can even represent several graphic objects in one window.

Fancier Graphic Objects

Listing Six contains definitions of four more complicated types of graphic objects. The point differs slightly from simple in that it expects the object to which it's attached to contain a list of at least two numbers. These become the x and y coordinates of the point. Gobs like this one are useful simulations of movement when you put several in one window.

(defgob 'point
  #'nilfn
  #.'(lambda (val window scale)
     (point window
       (* (car val) scale) (* (cadr val) scale))))
       
(setq dialmin 0 dialmax 100)

(defgob 'dial
   #'(lambda (val window scale)
   (circle window
      (* 50 scale) (* 50 scale)
      (* 48 scale)))
   #'(lambda (val window scale)
     (let ((theta (+ pi (' pi (div (- val dialmin)
                                   (- dialmax dialmin)))))
     (r (* 48 scale))
     (x1 (* 50 scale))
     (y1 (* 50 scale)))

     (line window
           x1 y1
           (+ x1 (* r (cos theta)))
           (+ y1 (* r (sin theta)))))))
(defgob 'function
   #'nilfn
   #'(lambda (vals window scale)
      (plotcurve window
           (sort vals #'(lambda (x y)
                     (< (car x) (car y))))
           scale)))

(defgob 'parametric
   #'nilfn
   #'(lambda (vals window scale)
   (plotcurve window vals scale)))

(defun plotcurve (window points scale)
   (unless < (length points) 2)
   (let ((p1 (car points)) (p2 (cadr points)))
      (line window
         (* (car p1) scale)
         (* (- 100 (cadr p1)) scale)
         (* (car p2) scale)
         (* (- 100 (cadr p2)) scale)))
(plotcurve window (cdr points) scale)))

Listing Six: Graphic object.

The second gob in Listing Six is the long-awaited dial. This version is reduced to its essentials, consisting only of a circle and a line. In actual use, dials have sort of minimum and maximum range -- here, dialmin and dialmax are by first set to 0 and 100, repectively. This range occupies the top 180 degrees of the dial, a situation easily altered by changing the culation of theta.

Finally, note that the dial, like all gobs for which size is an issue, is designed to fit by default into a 100-x-100 square, so that's how big it will be with scale 1.

The remaining gobs, function and parametric, assume that they are attached to an entry containing a list of (x,y) pairs. The only difference between the two is the order in which they connect these points: A function connects the order of increasing x coordinates; and a parametric connects them in the order in which they occur. Gobs like these take only slightly more effort to make than the simple gob, but a whole screen full of them working at runtime is a striking sight.

The graphic objects we've seen are really very simple. The point of this article is that making more complex ones does not mean starting over from scratch. When an interface is written in the style presented here, making it more sophisticated is largely a process of elaboration.

Terms of Service | Privacy Statement | Copyright © 2024 UBM Tech, All rights reserved.