User interfaces for object-oriented systems, Part 6: The RPN calculator

The RPN-calculator application demonstrates object-oriented UI principles

1 2 3 4 5 6 Page 4
Page 4 of 6
Listing 3: /src/rpn/Calculator_keypad.java

1: package rpn; 2: 3: import java.awt.*; 4: import java.awt.event.*; 5: import javax.swing.*; 6: 7: /** A "lightweight" component that implements a simple calculator-style 8: * keypad. 9: * 10: * This class is an example of what JFC calls a "view-controller." 11: * It encapsulates both view (drawing) and controller (event-receiver) 12: * behavior in a single class. That is, it both causes several 13: * buttons to appear on the screen (the view) and it also catches 14: * the action events sent by the buttons (the controller). 15: *

16: * The generated keypad looks like this: 17: *

18:  *
19:  *
  20:  **/
  21: 
  22: public class Calculator_keypad extends JPanel
  23: {
  24:     JLabel     accumulator = new JLabel("")
  25:                              {
  26:                               public void paint(Graphics g)
  27:                                 {
  28:                                     g.setColor( Color.black );
  29:                                     Dimension size = getSize(); 
  30:                                     g.fillRect( 0, 0, size.width, size.height );
  31:                                     super.paint( g );
  32:                                 }
  33:                              };
  34: 
  35:     Controller controller  = new Controller();  // an inner class,
  36:                                                 // defined below.
  37:     //----------------------------------------------------------------
  38:     // Create the keypad. To simplify the layout, we create an inner
  39:     // container that holds the numeric buttons, and an outer
  40:     // container that holds the inner one, along with a few other
  41:     // buttons for the + - * / and "Enter" key, and also a "label"
  42:     // that shows the partially-accumulated input string.
  43: 
  44:     Calculator_keypad()
  45:     {
  46:         accumulator.setForeground   (Color.green);
  47:         accumulator.setFont         (new Font("Monospaced",Font.BOLD, 14));
  48:         accumulator.setPreferredSize(new Dimension(100, 20) );
  49: 
  50:         JPanel keypad = new JPanel();
  51:         keypad.setLayout( new GridBagLayout() );
  52:         //        cont.   r  c  w  h  x  y  p  label/obj, action
  53:         add_cell( keypad, 2, 0, 1, 1, 1, 1, 0, "1"       , "1" );
  54:         add_cell( keypad, 2, 1, 1, 1, 1, 1, 0, "2"       , "2" );
  55:         add_cell( keypad, 2, 2, 1, 1, 1, 1, 0, "3"       , "3" );
  56:         add_cell( keypad, 1, 0, 1, 1, 1, 1, 0, "4"       , "4" );
  57:         add_cell( keypad, 1, 1, 1, 1, 1, 1, 0, "5"       , "5" );
  58:         add_cell( keypad, 1, 2, 1, 1, 1, 1, 0, "6"       , "6" );
  59:         add_cell( keypad, 0, 0, 1, 1, 1, 1, 0, "7"       , "7" );
  60:         add_cell( keypad, 0, 1, 1, 1, 1, 1, 0, "8"       , "8" );
  61:         add_cell( keypad, 0, 2, 1, 1, 1, 1, 0, "9"       , "9" );
  62:         add_cell( keypad, 3, 0, 2, 1, 2, 1, 0, "0"       , "0" );
  63:         add_cell( keypad, 3, 2, 1, 1, 1, 1, 0, "."       , "." );
  64: 
  65:         this.setLayout( new GridBagLayout() );
  66: 
  67:         add_cell( this,   0, 0, 2, 1, 4, 1, 3, accumulator, null );
  68:         add_cell( this,   1, 1, 1, 1, 1, 1, 3, "+"        , "+"  );
  69:         add_cell( this,   2, 1, 1, 1, 1, 1, 3, "-"        , "-"  );
  70:         add_cell( this,   3, 1, 1, 1, 1, 1, 3, "X"        , "*"  );
  71:         add_cell( this,   4, 1, 1, 1, 1, 1, 3, "/"        , "/"  );
  72:         add_cell( this,   4, 0, 1, 1, 4, 1, 3, "Enter"    , "\n" );
  73:         add_cell( this,   1, 0, 1, 3, 3, 4, 3, keypad     , null );
  74: 
  75:         setMinimumSize( getPreferredSize() );
  76:         setMaximumSize( getPreferredSize() );
  77:     }
  78:     //----------------------------------------------------------------
  79:   private void add_cell(  Container container,
  80:                             int row,    int col, 
  81:                             int width,  int height,
  82:                             int weightx,int weighty,
  83:                             int pad,
  84:                             Object item,
  85:                             String action_command )
  86:     {
  87:         GridBagConstraints constraints = new GridBagConstraints();
  88: 
  89:         constraints.fill        = GridBagConstraints.BOTH;
  90:         constraints.gridy       = row;
  91:         constraints.gridx       = col;
  92:         constraints.gridwidth   = width;
  93:         constraints.gridheight  = height;
  94:         constraints.weightx     = weightx;
  95:         constraints.weighty     = weighty;
  96:         constraints.insets      = new Insets(pad,pad,pad,pad);
  97: 
  98:         JComponent component;
  99:         if( item instanceof JComponent )
 100:             component = (JComponent)item;   
 101:         else                            // assume it's a string
 102:         {
 103:             // The "action command" holds the command sent to
 104:             // parse() to handle the button press. For the time
 105:             // being, that's the same as the button label, but it
 106:             // doesn't have to be. All buttons notify the same
 107:             // controller object.
 108: 
 109:             String label = (String) item;
 110:             JButton button = new JButton( label          );
 111:             button.addActionListener    ( controller     );
 112:             button.setActionCommand     ( action_command );
 113:             button.setFont
 114:             (   new Font("SansSerif",Font.BOLD,
 115:                 Character.isLetterOrDigit(label.charAt(0))? 12: 16 )
 116:             );
 117:             component = button;
 118:         }
 119: 
 120:         container.add( component );
 121:         component.setVisible( true );
 122: 
 123:         GridBagLayout layout = (GridBagLayout)container.getLayout();
 124:         layout.setConstraints( component, constraints );
 125:     }
 126:     //----------------------------------------------------------------
 127:   private ActionListener observers = null;
 128: 
 129:     /** The Calculator_keypad notifies action listeners when it
 130:      *  has accumulated a string for the listener to process. The
 131:      *  string itself is available as the "action command." Notifications
 132:      *  are sent when the user clicks on any of the operator keys
 133:      *  (+ - * /) or Enter. Numbers and decimal points are just
 134:      *  accumulated, and will be sent along with the arithmetic
 135:      *  character when the observers are notified. For example, if
 136:      *  you type a 1, 2, 3, *, the action event is sent when the * is
 137:      *  typed, and the action command is "123*". An "Enter" not
 138:      *  preceded with a number causes an empty-string action command
 139:      *  to be sent.
 140:      */
 141: 
 142:   public void addActionListener(ActionListener observer)
 143:     {   observers = AWTEventMulticaster.add(observers, observer);
 144:     }
 145: 
 146:   public void removeActionListener(ActionListener observer)
 147:     {   observers = AWTEventMulticaster.remove(observers, observer);
 148:     }
 149: 
 150:     //----------------------------------------------------------------
 151:     // The "controller" functionality. Receives notifications of button
 152:     // presses, accumulates them if necessary, and dispatches them off
 153:     // to the observers at appropriate times. Implementing the controller
 154:     // as a private inner class is better design than simply having
 155:     // Calculator_keypad implement Action Listener. The fact that
 156:     // a Calculator_keypad is an ActionListener is an implementation
 157:     // detail so it shouldn't be exposed publicly. The inner class also
 158:     // lets us effectively make the actionPerformed() method private.
 159:     // Otherwise actionPerformed() would have to be a public method
 160:     // of Calculator_keypad -- bad design because the outside
 161:     // world shouldn't know that the method exists, much less
 162:     // be able to call it. The "Controller" is not static since it
 163:     // accesses the "view" directly. This strong coupling is reasonable
 164:     // only because the Controller is effectively a member of
 165:     // Calculator_keypad, in the same way that a method is a member.
 166:      
 167:   private class Controller implements ActionListener
 168:     {
 169:       private final StringBuffer number_buffer = new StringBuffer();
 170: 
 171:       public void actionPerformed( ActionEvent e )
 172:         {   
 173:             if( observers != null )
 174:             {   String command = ((JButton)(e.getSource())).getActionCommand();
 175: 
 176:                 // Append text associated with the current button to
 177:                 // the number_buffer. (Append a space character for
 178:                 // the "Enter" key, but only if there's nothing in the
 179:                 // buffer.)
 180: 
 181:                 if( !command.equals("\n") )
 182:                     number_buffer.append( command );
 183: 
 184:                 else if( number_buffer.length() == 0 )
 185:                     number_buffer.append( " " );
 186: 
 187:                 // If the command is a dot or a base-10 digit, we're
 188:                 // accumulating a number -- display the current number_buffer
 189:                 // on the accumulator window. Otherwise, a non-number
 190:                 // was received: send the buffer off to the parser in
 191:                 // an ActionEvent, then clear the buffer.
 192: 
 193:                 if ( command.equals(".")
 194:                            || Character.digit(command.charAt(0),10)!=-1 )
 195:                 {
 196:                     accumulator.setText( " " + number_buffer.toString() );
 197:                 }
 198:                 else
 199:                 {   accumulator.setText( "" );
 200:                     observers.actionPerformed(
 201:                                 new ActionEvent(this, 0,
 202:                                                 number_buffer.toString())); 
 203:                     number_buffer.setLength(0);
 204:                 }
 205:             }
 206:         }
 207:     }
 208: 
 209:     /****************************************************************
 210:      *  The Test inner class encapsulates a main() that tests the
 211:      *  Calculator_keypad. I'm not putting main() into Calculator_keypad
 212:      *  itself because I don't want the test code in the final
 213:      *  application. A separate class creates a separate .class file
 214:      *  (called Calculator_keypad$Test.class) which does not have
 215:      *  to be included in the release version of the program.
 216:      **/
 217:   static public class Test
 218:     {
 219:       public static void main( String[] args )
 220:         {   JFrame frame = new JFrame();
 221:             Calculator_keypad keyboard = new Calculator_keypad();
 222: 
 223:             keyboard.addActionListener
 224:             (   new ActionListener()
 225:               {   public void actionPerformed( ActionEvent e )
 226:                     {   System.out.println( e.getActionCommand() );
 227:                     }
 228:                 }
 229:             );
 230: 
 231:             frame.addWindowListener
 232:             (   new WindowAdapter()
 233:               {   public void windowClosing( WindowEvent e )
 234:                     {   System.exit(0);
 235:                     }
 236:                 }
 237:             );
 238: 
 239:             frame.getContentPane().add( keyboard );
 240:             frame.pack();
 241:             frame.show();
 242:         }
 243:     }
 244: }   
         

The tape view

The tape-style view is also straightforward, though it uses a somewhat different internal architecture than does the keypad view (see Figure 12). As is the case with the keypad, the Tape is a Jpanel, but it contains only two fields: a JTextField (input: see Listing 4, line 30) into which you type your input and a Scrollable_JTextArea (output: see Listing 4, line 31), which stores a record of your data entry in a manner similar to the tape on a tape calculator. A log file (log: see Listing 4, line 33) -- implemented using the Log class discussed in January's Java Toolbox -- also stores the contents of this window. As is also the case with the keypad, any object interested in input from this view should call addActionListener(...) (Listing 4, line 191) to register as an ActionListener in the observers list.

Figure 12. The tape-style calculator UI

In contrast to the keypad view with its listener-based strategy, the tape view employs a Swing-model object to discover when the user enters characters. For reasons that are not clear to me, the JTextField doesn't support the AWT TextField's TextListener (which was notified when new text was entered into the control). Instead, JTextField supports an extremely complex set of listeners such as the CaretListener, which is notified when the cursor position changes, but none of these let you easily find out when the text changes.

The easiest way to trap character-by-character text entry is to provide an implementation of the PlainDocument -- the Swing model associated with the text objects -- and override its insertString() method, which is called when a character is inserted into the string. I've done that in insertString(...) (Listing 4, line 94), which works much like the keypad's Controller object, accumulating alphanumeric strings until it encounters a non-alphanumeric character, but processing non-alphanumerics immediately. The Model object calls process_newline() (Listing 4, line 123) to dispatch the string off to the listeners by sending an actionPerformed() message to the observers multicaster (Listing 4, line 190).

An instance of the model is installed into the JTextArea UI delegate when the object is created on line 30. (See Resources for more on the Swing architecture.)

Another main difference between the keypad and the tape view is that you can write to a tape view. (The messages are displayed on the tape.) There are two string overrides of write: They are write(String,Color) (Listing 4, line 142) and write(String) (Listing 4, line 145). There's another for printing numbers (write(double): see Listing 4, line 154). Strings are just appended to the current line buffer (buffer: see Listing 4, line 32) and flushed to the tape when a new line is encountered. Numbers are formatted consistently and then appended to the buffer.

Listing 4: /src/rpn/Tape.java
   1: package rpn;
   2: 
   3: import java.awt.*;
   4: import javax.swing.*;
   5: import javax.swing.text.*;
   6: import java.awt.event.*;
   7: import java.util.*;
   8: import java.io.*;
   9: import java.text.*;
  10: 
  11: import com.holub.tools.debug.Assert;
  12: import com.holub.ui.Scrollable_JTextArea;
  13: import com.holub.io.Log;
  14: 
  15: import com.holub.string.Align;
  16: 
  17: /** A Tape is a 2-D text control for use in a calculator-style
  18:  *  application. It does several things:
  19:  *  
  1. 20:  *
  2. Accumulates all input strings until an 21:  * Enter or arithmetic operator (* - / *) is entered. 22:  *
  3. Notifies any ActionListeners when an 23:  * Enter or arithmetic operator (* - / *) is entered. The 24:  * accumulated string is available in the action command. 25:  *
  26:  **/
  27: 
  28: public class Tape extends JPanel
  29: {
  30:   private final JTextField           input =new JTextField(new Model(),"",128);
  31:   private final Scrollable_JTextArea output=new Scrollable_JTextArea(true);
  32:   private final StringBuffer         buffer=new StringBuffer();
  33:   private static final Log           log   =new Log();
  34: 
  35:   public Tape()
  36:     {   
  37:         output.setVerticalScrollBarPolicy
  38:                                 (JScrollPane.VERTICAL_SCROLLBAR_ALWAYS );
  39:         output.setHorizontalScrollBarPolicy
  40:                             (JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
  41: 
  42:         output.setBackground( new Color(255, 255, 204) ); // light yellow
  43:         output.setForeground( Color.black );
  44:         output.setFont     ( new Font("Monospaced",Font.BOLD,11) );
  45:         output.setLineWrap  ( false );
  46: 
  47:         input.addActionListener
  48:         (   new ActionListener()
  49:           {   public void actionPerformed(ActionEvent e)
  50:                 {   process_newline();
  51:                 }
  52:             }
  53:         );
  54: 
  55:         addFocusListener
  56:         (   new FocusAdapter()
  57:           {   public void focusGained(FocusEvent e)
  58:                 {   input.requestFocus();
  59:                 }
  60:             }
  61:         );
  62: 
  63:         output.addFocusListener     // Not working for some reason
  64:         (   new FocusAdapter()
  65:           {   public void focusGained(FocusEvent e)
  66:                 {   input.requestFocus();
  67:                 }
  68:             }
  69:         );
  70: 
  71:         setMinimumSize  ( new Dimension(800,100) );
  72:         setPreferredSize( new Dimension(800,100) );
  73:         setSize         ( new Dimension(800,100) );
  74: 
  75:         setLayout( new BorderLayout() );
  76:         add( output, "Center" );
  77:         add( input,  "South"  );
  78:         setVisible( true );
  79:     }
  80: 
  81:     /*******************************************************************
  82:      **/
  83: 
  84:   private final class Model extends PlainDocument
  85:     {
  86:         /** Called when a character is inserted into the string, either
  87:          *  by typing or by a paste operation.
  88:          *
  89:          * @param   value   holds inserted text
  90:          * @param   offset  is the position of the first character of the
  91:          *                  insertion (first character in string is 0).
  92:          * @param   a       is not used.
  93:          */
  94:       public void insertString(int offs, String value, AttributeSet a)
  95:                                             throws BadLocationException
  96:         {
  97:             if (value == null || (value.length() <= 0) )
  98:                 return;
  99: 
 100:             // Must chain to base-class version to get the characters
 101:             // to actually appear in the control.
 102: 
 103:             super.insertString(offs, value, a);
 104: 
 105:             // Alphanumerics are accumulated until a non-alpha is
 106:             // entered. Then the listeners are signaled.
 107: 
 108:             char last = value.charAt( value.length() -1 );
 109:             if ( !is_number(last) )
 110:                 process_newline();
 111: 
 112:         }
 113: 
 114:         /***************************************************************
 115:          * Returns true if the character is legitimate in a number
 116:          * (a digit or decimal point).
 117:          */
 118:       private final boolean is_number( char c )
 119:         {   return c=='.' || Character.isDigit©;
 120:         }
 121:     }
 122: 
 123:   private void process_newline()
 124:     {   String line = input.getText();
 125:         input.setText("");
 126:         observers.actionPerformed( new ActionEvent( Tape.this,0, line));
 127:     }
 128: 
 129:     //------------------------------------------------------------
 130:   public void requestFocus()
 131:     {   input.requestFocus(); // Transfer focus to the input window
 132:     }
 133: 
 134:     /****************************************************************
 135:      * Write a string on the tape. Text is not
 136:      * written to the tape until a "\n" is appended. Appended
 137:      * strings are treated just as if a user had typed them.
 138:      * @param text The string to append
 139:      * @param color The color to print the string (currently not
 140:      *              supported).
 141:      */
 142:   public void write( String text, Color color )
 143:     {   do_write(text, color);
 144:     }
 145:   public void write( String text )
 146:     {   do_write(text, Color.black );
 147:     }
 148: 
 149:     /****************************************************************
 150:      * A variant on write(String,Color) that appends a double.
 151:      * The color is red if the number is negative, black if it's
 152:      * positive.
 153:      */
 154:   public void write( double value )
 155:     {
 156:         DecimalFormat compositor = new DecimalFormat("#,##0.00##");
 157:         String formatted = Align.align( compositor.format( value ),
 158:                                            16,  // output column width
 159:                                            11,  // alignment column
 160:                                            '.', // align on this character
 161:                                            ' '  // pad with spaces
 162:                                            );
 163: 
 164:         do_write( formatted, value < 0 ? Color.red : Color.black );
 165:     }
 166: 
 167:     //------------------------------------------------------------
 168:     // Write the screen into the output window (the "tape")
 169: 
 170:   private void do_write( String text, Color color/*ignored*/ )
 171:     {   
 172:         buffer.append(text);
 173:         if( text.endsWith( "\n" ) )
 174:         {   String padded = Align.right( buffer.toString(), 22 );
 175:             output.append( padded );
 176:             log.write    ( padded );
 177: 
 178:             buffer.setLength(0);
 179:         }
 180:     }
 181:     //------------------------------------------------------------
 182:   public Dimension getPreferredSize()
 183:     {   return new Dimension( /*width*/ 100, /*height*/ 50 );
 184:     }
 185:     //------------------------------------------------------------
 186:   public Dimension getMinimumSize()
 187:     { return getPreferredSize();
 188:     }
 189:     //----------------------------------------------------------------
 190:   private ActionListener observers = null;
 191:   public void addActionListener(ActionListener observer)
 192:     {   observers = AWTEventMulticaster.add(observers, observer);
 193:     }
 194:   public void removeActionListener(ActionListener observer)
 195:     {   observers = AWTEventMulticaster.remove(observers, observer);
 196:     }
 197: }
         

The Parser itself

Now we can look at the Parser class itself, in Listing 5.

You'll find the UML in Figure 13. Starting at the UML's bottom, the Parser needs to talk to both the Keypad_viewer and the Tape_viewer in a consistent way. Though these classes are similar, the Tape supports write() methods and the Calculator_keypad doesn't. I'm reluctant, in a situation such as this, to introduce bogus write() methods to a class like Calculator_keypad, which has no possible valid implementation of those methods. The only robust implementation is to throw an exception if the bogus method is called, but I don't like to move a compile-time error ("method not found") into a runtime error (the exception toss).

The problem can be solved with the Gang of Four Adapter pattern, in this case a Class Adapter. Viewer_ui (Listing 5, line 264) defines a uniform interface to the two view classes. I then implement the interface in two adapters: The Keypad_viewer (Listing 5, line 272) is a Calculator_keypad (it extends Calculator_keypad) that implements the Viewer_ui, and Tape_viewer (Listing 5, line 278) is a Tape that implements the Viewer_ui. The Keypad_viewer implements empty write methods, since in the current context it isn't an error to throw away the output. The Tape_viewer interestingly, has no methods at all: all the methods that the Viewer_ui interface requires are inherited from the Tape base class. Java, unlike C++, doesn't need us to supply any derived-class methods in this situation.

Figure 13. The Parser input processor

As we see at the center of Figure 13, the Parser's visual proxy is an instance of Viewer (Listing 5, line 288), which is a JPanel that contains either a Calculator_keypad or a Tape, depending on which UI the user requests. The Viewer's other roles include: installing the Interface menu we discussed earlier on the main-frame's menu bar and dynamically updating the menu bar with the menu items appropriate for the current view. Finally, the Parser can write messages on the Viewer, which relays the messages to the current contained view object.

Related:
1 2 3 4 5 6 Page 4
Page 4 of 6