Notes on GUI design, based on observations of the Gobblet GUI implementations. Barely coherent at best. My overall conclusion is that we don't yet know the right way to implement GUIs, but it looks like we have a lot of the pieces. - Matthew ---------------------------------------- Exhibit A: Modality via state // In a drawing-area widget: method handle-mouse-event (...) = if mouse-down? ... compute clicked-item ... ... set! dragging clicked-item else if mouse-move? if dragging ... else if mouse-up? if dragging && ok-move ... The problems with this pattern are 1) Information to be used from one GUI mode to another must be manually packaged and unpackaged across calls to handle-mouse-event. 2) The GUI mode is implemented by a state variable, which has all the problems of state. 3) It's not clear that all modes are covered, because the implementation of the mode is distribited. (For example, did we handle the case that the mouse was released and the move is not ok?) Exhibit B: Waiting for mouse clicks // After setting up a drawing/clicking area: loop (...) = let x1,y1 = get-next-click ... if bad-click? loop(...) // no change let x2,y2 = get-next-click ... if bad-move? loop(...) // no change loop(do-move(...)) This is much better: 1) No need to package info from first mode to second --- just use lexical scope. 2) There's no state. 3) All conditional clauses filled, so all states are covered. Problem: This pattern only works for a linear progression of modes. It doesn't handle, for example, clicks to adjust the board size or color preferences. Exhibit C: Servlets JSP-style servlet implemenation suffers from the same problem as Exhibit A. A better solution is to use direct style in a continuation-based web server (e.g., the PLT Scheme web server, which has been used to implement the Continue conference-management system) Caveat: pure HTML interfaces are rigid compared to a normal GUI. The browser interface encourages a linear progression that works well for Exhibit-B-style direct expression. Exhibit D: Continuation Passing Style CPS is useful for handling multiple directions for a continuation (e.g., success versus failure). Converting a program to use CPS completely (disallowing all nested function calls), however, is exceedingly painful. The problem in Exhibit A and JSP-style servlets is analogous to CPS, so it's not surprising that a direct style seems better. But we should also look for something analogous to CPS to be better in other cases. In particular, maybe the anologue of CPS is the right thing for non-linear progression. Exhibit E: Concurrent ML and eXene CML processes can encapsulate state in much the same way that the direct style of Exhibit B encapsulates information that is recorded via state in Exhibit A. Also, there's a well-understood connection between processes and continuations. A thread and a continuation both represent work to be done. More concretely, threads can be implemented in terms of continations. The eXene GUI library builds on Exhbibit-B-style process loops (instead of event callbacks) to create a widget set based on CML processes. Multiple processes support multiple state progressions, avoiding the main problem with Exhibit B. Multiple processes introduce a new problem, however, in the form of race conditions. Suppose that we have a GUI containing a text field, a "Save" button, and an "Erase" button: ------------------------------------------- | ---------------------------- ,-----. | | | Hello | | Save | | | | | `-----' | | | | ,-----. | | | | | Erase | | | ---------------------------- `-----' | -------------------------------------------- Each widget has a process. The text widget is implemented as a loop of the form loop (string content, int caret) = sync( handle-evt (channel-get-evt text-key-ch) (lambda (key) loop(insert(content, caret, key), caret+1)), handle-evt (channel-put-evt text-content-ch content) (lambda (ignored) loop(content, caret)), handle-evt (channel-get-evt text-content-ch) (lambda (new-content) loop(new-content, caret)) ) The Save button would be something like loop () = sync( handle-evt (channel-get-evt save-click-ch) (lambda (ignored) save-to-file(sync(channel-get-evt text-content-ch)) loop()) ) and the Erase button would be something like loop () = sync( handle-evt (channel-get-evt erase-click-ch) (lambda (ignored) sync(channel-put-evt text-content-ch "") loop()) ) The event manager would dispatch mouse and key events to text-key-ch, save-click-ch, and erase-click-ch. To see the problem with this implementation, suppose that the user clicks Save and then Erase. The user's intent, of course, is to save before erasing. However, the event manager is free to dispatch the second click as soon as the first click is received by the Save button. There's no guarantee that the Save button will completely handle the click before the Erase button handles its click. If the Erase button proceeds first, then the saved text will be empty. Despite this problem, concurrent processes seem like a promising way to handle enabling buttons or updating other indicators in a GUI. These updates could be implemented modularly as little processes whose job is to detect that some state has changed and update the GUI accordingly. In defining these processes, however, we have to beware of introducing even more race conditions. Exhibit F: Avoiding race conditions The race condition in the eXene example could be avoided by having the event manager wait for a signal from each event recipient to indicate that the event has been completely handled. This extra synchronizaiton effectively makes the GUI single-threaded in the same way as a callback-based system, because only one process handles an event at a time. But where should the "done handling event" message be sent? Requiring every event handler to manually send a "done" event is fragile; the implementor of the Save button, for example, might forget to send the event before calling "loop()", leading to deadlock in the GUI. The message cannot be bundled easily with the event handler by replacing "handle-evt" with "handle-gui-evt" handle-gui-evt(evt, f) = handle-evt(evt, (lambda (v) f(v) sync(channel-put-evt done-ch #t))) because a handler function `f' typically wants to loop, and therefore must be called in tail position. If we try something like handle-gui-evt(evt, f, k) = handle-evt(evt, (lambda (v) f(v) sync(channel-put-evt done-ch #t) k())) where the loop is attached to `k' instead of `f', we get the ordering right, but we lose the ability to loop with information computed in handling the event, as needed in the text-field key handler. We can solve this last problem by making `k' consume the result from `f': handle-gui-evt(evt, f, k) = handle-evt(evt, (lambda (v) let v2 = f(v) sync(channel-put-evt done-ch #t) k(v2))) but now we're starting to write handler parts in CPS, which is awkward. Perhaps we want a "done" to be sent automatically every time we go into the sync at the beginning ofa widget's loop. But not all of the events for a widget correspond to GUI events; for example, `text-content-ch' for a text wigdet sends to receives the widget's content indpendent of GUI event handling. Maybe it should be thought of as a GUI event, instead.