- 9.1 The Core: Model-View Separation
- 9.2 The Model-View-Controller Pattern
- 9.3 The JFace Layer
- 9.4 The MVC Pattern at the Application Level
- 9.5 Undo/Redo
- 9.6 Wrapping Up
9.4 The MVC Pattern at the Application Level
So far, we have looked at the basic MODEL-VIEW-CONTROLLER pattern and its implementation in the JFace framework. The examples have been rather small and perhaps a little contrived, to enable us to focus on the mechanisms and crucial design constraints. Now it is time to scale the gained insights to the application level. The question we will pursue is how model-view separation influences the architecture of the overall product. Furthermore, we will look at details that need to be considered for this scaling, such as incremental repainting of the screen.
The running example will be a minimal spreadsheet application Mini-Xcel (Fig. 9.12). In this application, the user can select a cell in a special widget displaying the spreadsheet, and can enter a formula into that cell, possibly referring to other cells. The application is responsible for updating all dependent cells automatically, as would be expected.
Figure 9.12 The MiniXcel Application
The application offers enough complexity to explore the points mentioned previously. First, the model contains dependencies between cells in the form of formulas, and the parsing of and computation with formulas constitutes a nontrivial functionality in itself. At the interface level, we need a custom-painted widget for the spreadsheet, which must also offer view-level visual feedback and a selection mechanism to link the spreadsheet to the input line on top.
9.4.1 Setting up the Application
The overall structure of the application is shown in Fig. 9.13. The Spread Sheet encapsulates the functional core. It manages cells, which can be addressed 9.1 from the outside by usual coordinates such as A2 or B3, as well as their interdependencies given by the stored formulas. A formula is a tree-structured COMPOSITE that performs the actual computations. A simple 2.3.1 (shift-reduce) parser transforms the input strings given by the user 2 into structured formulas. The core point of model-view separation is implemented 9.1 by making all functionality that is not directly connected to the user interface completely independent of considerations about the display.
Figure 9.13 Structure of the MiniXcel Application
The main window (Fig. 9.12) consists of two parts: the SpreadSheet View at the bottom and the CellEditor at the top. These two are coupled 12.1 loosely: The SpreadSheetView does not assume that there is a single Cell Editor. Instead, it publishes a generic IStructuredSelection containing the currently selected Cell model element. When the user presses “enter,” 9.3.2 the cell editor can simply call setFormula on that Cell. This has two effects. First, the dependent cells within the spreadsheet are updated by reevaluating their formulas. Second, all updated cells will notify the view, through their surrounding SpreadSheet model. 2.2.4
9.4.2 Defining the Model
We can give here only a very brief overview of the model code and highlight those aspects that shape the collaboration between user interface and model. The central element of the model is the SpreadSheet class. It keeps a sparse mapping from coordinates to Cells (line 2) and creates cells on demand as they are requested from the outside (lines 5–12). The model implements the OBSERVER pattern as usual to enable the view to remain up-to-date (lines 4, 14–16, 18–20). The class Coordinates merely stores a row and column of a cell.
minixcel.model.spreadsheet.SpreadSheet
1 public class SpreadSheet { 2 private final HashMap<Coordinates, Cell> cells = 3 new HashMap<Coordinates, Cell>(); 4 private final ListenerList listeners = new ListenerList(); 5 public Cell getCell(Coordinates coord) { 6 Cell res = cells.get(coord); 7 if (res == null) { 8 res = new Cell(this, coord); 9 cells.put(coord, res); 10 } 11 return res; 12 } 13 14 public void addSpreadSheetListener(SpreadSheetListener l) { 15 listeners.add(l); 16 } 17 ... 18 void fireCellChanged(Cell cell) { 19 ... 20 } 21 ... 22 }
Application models usually have internal dependencies.
Each Cell in the spreadsheet must store the user’s input (line 4 in the next code snippet) and must be prepared to evaluate that formula quickly (line 5). Since the view will query the current value rather frequently and other cells will require it for evaluating their own formulas, it is sensible to cache that value rather than repeatedly recomputing it (line 6). As further 2.2.1 basic data, the cell keeps its owner and the position in that owner (lines 2–3).
The example of spreadsheets also shows that an application model is 1.3.3 rarely as simple as, for instance, a list of Java beans. Usually, the objects within the model require complex interdependencies and collaborations to implement the desired functionality. In Cells, we store the (few) cross references introduced by the formula in two lists: dependsOn lists those cells whose values are required in the formula; dependentOnThis is the inverse relationship, which is required for propagating updates through the spreadsheet.
minixcel.model.spreadsheet.Cell
1 public class Cell { 2 final SpreadSheet spreadSheet; 3 private final Coordinates coord; 4 private String formulaString = ""; 5 private Formula formula = null; 6 private Value cachedValue = new Value(); 7 private final List<Cell> dependsOn = new ArrayList<Cell>(); 8 private final List<Cell> dependentOnThis = 9 new ArrayList<Cell>(); 10 11 ... 12 }
Clients cannot adequately anticipate the effects of an operation.
One result of the dependencies within the model is that clients, such as the controllers in the user interface, cannot foresee all the changes that are effected by an operation they call. As a result, the controller of the MVC could not reliably notify the view about necessary repainting even without interference from other controllers. This fact reinforces the crucial design 9.2.3 decision of updating the view by observing the model.
In the current example, the prototypical modification is setting a new formula on a cell. The overall approach is straightforward: Clear the old dependency information, and then set and parse the new input. Afterward, we can update the new dependencies by asking the formula for its references and recomputing the current cached value.
minixcel.model.spreadsheet.Cell
1 public void setFormulaString(String formulaString) { 2 clearDependsOn(); 3 this.formulaString = formulaString; 4 ... special cases such as an empty input string 5 formula = new Formula(spreadSheet.getFormulaFactory(), 6 formulaString); 7 fillDependsOn(); 8 ... check for cycles 9 recomputeValue(); 10 }
The update process of a single cell now triggers updating the dependencies as well: The formula is evaluated and the result is stored.
minixcel.model.spreadsheet.Cell.recomputeValue
private void recomputeValue() { ... setCachedValue(new Value(formula.eval( new SpreadSheetEnv(spreadSheet)))); ... error handling on evaluation error }
The cache value is therefore the “current” value of the cell. Whenever that changes, two stakeholders must be notified: the dependent cells within the spreadsheet and the observers outside of the spreadsheet. Both goals are accomplished in the method setCachedValue():
minixcel.model.spreadsheet.Cell
protected void setCachedValue(Value val) { if (val.equals(cachedValue)) return; cachedValue = val; for (Cell c : dependentOnThis) c.recomputeValue(); spreadSheet.fireCellChanged(this); }
This brief exposition is sufficient to highlight the most important points with respect to model-view separation. Check out the online supplement for further details—for instance, on error handling for syntax errors in formulas and cyclic dependencies between cells.
9.4.3 Incremental Screen Updates
Many applications of model-view separation are essentially simple, with small models being displayed in small views. Yet, one often comes across the other extreme. Even a simple text viewer without any formatting must be careful to repaint only the portion of text determined by the scrollbars, and from that only the actually changing lines. Otherwise, the scrolling and editing process will become unbearably slow. The MiniXcel example is sufficiently complex to include a demonstration of the necessary processes.
Before we delve into the details, Fig. 9.14 gives an overview of the challenge. 7.8 Put very briefly, it consists of the fact that even painting on the screen is event-driven: When a change notification arrives from the model, one never paints the corresponding screen section immediately. Instead, one asks to be called back for the job later on. In some more detail, the model on the left in Fig. 9.14 sends out some change notification to its observers. The view must then determine where it has painted the modified data. That area of the screen is then considered “damaged” and is reported to the window system. The window system gathers such damaged areas, subtracts any parts that are not visible anyway, coalesces adjacent areas, and maybe performs some other optimizations. In the end, it comes back to the view requesting a certain area to be repainted. At this point, the view determines the model elements overlapping this area and displays them on the screen.
Figure 9.14 Process of Incremental Screen Updates
A further reason for this rather complex procedure, besides the possibility of optimizations, is that other events, such as the moving and resizing of windows, can also require repainting, so that the right half of Fig. 9.14 would be necessary in any case. The extra effort of mapping model elements to screen areas in the left half is repaid by liberating the applications of optimizing the painting itself.
Let us track the process in Fig. 9.14 from left to right, using the concrete example of the MiniXcel SpreadSheetView. At the beginning, the view receives a change notification from the model. If the change concerns a single cell, that cell has to be repainted.
minixcel.ui.spreadsheet.SpreadSheetView.spreadSheetChanged
public void spreadSheetChanged(SpreadSheetChangeEvent evt) { switch (evt.type) { case CELL: redraw(evt.cell.getCoordinates()); break; ... } }
It will turn out later that cells need to be repainted on different occasions, such as to indicate selection or mouse hovering. We therefore implement 9.4.4 the logic in a helper method, shown next. The method redraw() 1.4.8 1.4.5 called on the mainArea of the view is provided by SWT and reports the area as damaged.
minixcel.ui.spreadsheet.SpreadSheetView
public void redraw(Coordinates coords) { Rectangle r = getCellBounds(coords); mainArea.redraw(r.x, r.y, r.width, r.height, false); }
In a real implementation, the method getCellBounds() would determine the coordinates by the sizes of the preceding columns and rows. To keep the example simple, all columns have the same width and all rows have the same height in MiniXcel. This finishes the left half of Fig. 9.14. Now it is the window system’s turn to do some work.
minixcel.ui.spreadsheet.SpreadSheetView
protected Rectangle getCellBounds(Coordinates coords) { int x = (coords.col - viewPortColumn) * COL_WIDTH; int y = (coords.row - viewPortRow) * ROW_HEIGHT; return new Rectangle(x, y, COL_WIDTH, ROW_HEIGHT); }
In the right half of Fig. 9.14, the MainArea is handed a paint request for a given rectangular area on the screen, in the form of a PaintEvent passed to the method shown next. This method determines the range of cells touched by the area (line 3). Then, it paints all cells in the area in the nested loops in lines 7 and 11. As an optimization, it does not recompute the area covered by each cell, as done for the first cell in line 5. Instead, it moves that area incrementally, using cells that are adjacent in the view (lines 9, 14, 16).
minixcel.ui.spreadsheet.MainArea.paintControl
1 public void paintControl(PaintEvent e) { 2 ... prepare colors 3 Rectangle cells = view.computeCellsForArea(e.x, e.y, e.width, 4 e.height); 5 Rectangle topLeft = view.computeAreaForCell(cells.x, cells.y); 6 Rectangle cellArea = Geometry.copy(topLeft); 7 for (int row = cells.y; row < cells.y + cells.height; row++) { 8 cellArea.height = SpreadSheetView.ROW_HEIGHT; 9 cellArea.x = topLeft.x; 10 cellArea.width = SpreadSheetView.COL_WIDTH; 11 for (int col = cells.x; 12 col < cells.x + cells.width; col++) { 13 paintCell(col, row, cellArea, gc); 14 cellArea.x += cellArea.width; 15 } 16 cellArea.y += cellArea.height; 17 } 18 }
The actual painting code in paintCell() is then straightforward, if somewhat tedious. It has to take into account not only the cell content, but also the possible selection of the cell and a mouse cursor being inside, both of which concern view-level logic treated in the next section. Leaving all of that aside, the core of the method determines the current cell value, formats it as a string, and paints that string onto the screen (avoiding the creation of yet more empty cells):
minixcel.ui.spreadsheet.MainArea
private void paintCell(int col, int row, Rectangle cellArea, GC gc) { if (view.model.hasCell(new Coordinates(col, row))) { cell = view.model.getCell(new Coordinates(col, row)); Value val = cell.getValue(); String displayText; displayText = String.format("%.2f", val.asDouble()); gc.drawString(displayText, cellArea.x, cellArea.y, true); } }
This final painting step finishes the update process shown in Fig. 9.14. In summary, incremental repainting achieves efficiency in user interface programming: 2.1.3 The view receives detailed change notifications, via the “push” variant of the OBSERVER pattern, which it translates to minimal damaged areas on the screen, which get optimized by the window system, before the view repaints just the model elements actually touched by those areas.
9.4.4 View-Level Logic
9.2.5 We have seen in the discussion of the MVC pattern that widgets usually include behavior such as visual feedback that is independent of the model itself. MiniXcel provides two examples: selection of cells and feedback about the cell under the mouse. We include them in the discussion since this kind of behavior must be treated with the same rigor as the model: Users consider only applications that react consistently and immediately as trustworthy.
Treat selection as view-level state.
Most widgets encompass some form of selection. For instance, tables, lists, 9.3.2 and trees allow users to select rows, which JFace maps to the underlying model element rendered in these rows. The interesting point about selection is that it introduces view-level state, which is orthogonal to the application’s core model-level state.
We will make our SpreadSheetView a good citizen of the community by implementing ISelectionProvider. That interface specifies that clients can query the current selection, set the current selection (with appropriate elements), and listen for changes in the selection. The last capability will also enable us to connect the entry field for a cell’s content to the spreadsheet (Fig. 9.13). For simplicity, we support only single selection and introduce a corresponding field into the SpreadSheetView.
minixcel.ui.spreadsheet.SpreadSheetView
Cell curSelection;
The result of querying the current selection is a generic ISelection. Viewers that map model elements to screen elements, such as tables and trees, usually return a more specific IStructuredSelection containing these elements. We do the same here with the single selected cell.
minixcel.ui.spreadsheet.SpreadSheetView.getSelection
public ISelection getSelection() { if (curSelection != null) return new StructuredSelection(curSelection); else return StructuredSelection.EMPTY; }
Since the selection must be broadcast to observers and must be mirrored on the screen, we introduce a private setter for the field.
minixcel.ui.spreadsheet.SpreadSheetView
private void setSelectedCell(Cell cell) { if (curSelection != cell) { Cell oldSelection = curSelection; curSelection = cell; fireSelectionChanged(); ... update screen from oldSelection to curSelection } }
The remainder of the implementation of the OBSERVER pattern for selection 2.1 is straightforward. However, its presence reemphasizes the role of selection as proper view-level state.
Visual feedback introduces internal state.
The fact that painting is event-driven, so that a widget cannot paint visual 7.8 feedback immediately, means that the widget must store the desired feedback as private state, determine the affected screen regions, and render the feedback in the callback (Fig. 9.14).
For MiniXcel, we wish to highlight the cell under the mouse cursor, so that users know which cell they are targeting in case they click to select it. The required state is a simple reference. However, since the state is purely 9.4.2 view-level, we are content with storing its coordinates; otherwise, moving over a yet unused cell would force the model to insert an empty Cell object.
minixcel.ui.spreadsheet.SpreadSheetView
Coordinates curCellUnderMouse;
Setting a new highlight is then similar to setting a new selected cell:
minixcel.ui.spreadsheet.SpreadSheetView
protected void setCellUnderMouse(Coordinates newCell) { if (!newCell.equals(curCellUnderMouse)) { Coordinates oldCellUnderMouse = curCellUnderMouse; curCellUnderMouse = newCell; ... update screen from old to new } }
The desired reactions to mouse movements and clicks are implemented by the following simple listener. The computeCellAt() method returns the cell’s coordinates, also taking into account the current scrolling position. While selection then requires a real Cell object from the model, the targeting feedback remains at the view level.
minixcel.ui.spreadsheet.SpreadSheetView.mouseMove
public void mouseMove(MouseEvent e) { setCellUnderMouse(computeCellAt(e.x, e.y)); } public void mouseDown(MouseEvent e) { setSelectedCell(model.getCell(computeCellAt(e.x, e.y))); }
The painting event handler merges the visual and model states.
The technical core of visual feedback and view-level state, as shown previously, is not very different from the model-level state. When painting the widget, we have to merge the model- and view-level states into one consistent overall appearance. The following method achieves this by first painting the cell’s content (lines 4–5) and overlaying this with a frame, which is either a selection indication (lines 9–12), the targeting highlight (lines 13–17), or the usual cell frame (lines 19–23).
minixcel.ui.spreadsheet.MainArea
1 private void paintCell(int col, int row, 2 Rectangle cellArea, GC gc) { 3 ... 4 displayText = String.format("%.2f", val.asDouble()); 5 gc.drawString(displayText, cellArea.x, cellArea.y, true); 6 Rectangle frame = Geometry.copy(cellArea); 7 frame.width-; 8 frame.height-; 9 if (view.curSelection != null && view.curSelection == cell) { 10 gc.setForeground(display.getSystemColor( 11 SWT.COLOR_DARK_BLUE)); 12 gc.drawRectangle(frame); 13 } else if (view.curCellUnderMouse != null 14 && view.curCellUnderMouse.col == col 15 && view.curCellUnderMouse.row == row) { 16 gc.setForeground(display.getSystemColor(SWT.COLOR_BLACK)); 17 gc.drawRectangle(frame); 18 } else { 19 gc.setForeground(display.getSystemColor(SWT.COLOR_GRAY)); 20 int bot = frame.y + frame.height; 21 int right = frame.x + frame.width; 22 gc.drawLine(right, frame.y, right, bot); 23 gc.drawLine(frame.x, bot, right, bot); 24 } 25 }
According to this painting routine, the view-level state is always contained within the cells to which it refers. It is therefore sufficient to repaint these affected cells when the state changes. For the currently selected cell, the code is shown here. For the current cell under the mouse, it is analogous.
minixcel.ui.spreadsheet.SpreadSheetView
private void setSelectedCell(Cell cell) { if (curSelection != cell) { ... if (oldSelection != null) redraw(oldSelection.getCoordinates()); if (curSelection != null) redraw(curSelection.getCoordinates()); } }
This code is made efficient through the incremental painting pipeline shown in Fig. 9.14 on page 500 and implemented in the code fragments shown earlier. Because the pipeline is geared toward painting the minimal necessary number of cells, it can also be used to paint single cells reliably and efficiently.