Cartesian Plane Lesson 16 Page 9: Completing the LinePropertiesPanel

LinePropertiesPanel, GUI Testing

On the previous page we developed the code to assemble the LinePropertiesPanel but omitted a detailed discussion of how to make it work, such as:

  • When we click a radio button on the left side of the panel, how do we update the components on the right side?
  • What do we do when the Apply button is clicked?
  • What do we do when the Reset button is clicked?

Most of the tasks we need to perform in order to complete the LinePropertiesPanel come under the heading of event processing, so that’s what we’ll be doing on this page.

GitHub repository: Cartesian Plane Part 16

Previous lesson: Cartesian Plane Lesson 16 Page 8: Constructing the LinePropertiesPanel

Event Processing Tasks, Overview

Let’s first take a look at an overview of the work we have left to do:

  1. Initialization
    When the panel is displayed all sorts of things need to happen, such as reading all the necessary property values from the PropertyManager. But that’s already taken care of, when we create the appropriate subclass of LinePropertySet for each of the radio buttons on the left side of the panel. One task that we still have to attend to is initializing the components on the right side of the panel; that’s what happens anyway when a radio button is selected, so to complete this task all we have to do is select the first radio button.
  2. Radio Button Deselection
    Deselection is the first thing that happens when the operator selects an unselected radio button: the already selected button gets deselected. Since we’re listening to button activity with an ItemListener, we’ll get an event telling us that this happened. Because some of the components on the right side of the panel may have been changed, we have to transfer their values to the LinePropertySet being managed by the deselected button. We’ll call this a synch-left operation.
  3. Radio Button Selection
    Upon selection, the values in the LinePropertySet being managed by the selected button have to be copied to the components on the right; we’ll call this a synch-right operation.
  4. Apply Button Selection
    When the Apply button is selected we have to update the PropertyManager with all the values associated with all the LinePropertySets. Note that all the property sets should be up to date with any changes the operator may have made except for the currently selected button. So we’ll do a synch-left operation, then call the apply method for each of the LinePropertySets being managed by the radio buttons.
  5. Reset Button Selection
    To process this event we just have to call the reset methods for each of the LinePropertySets, then do a synch-right for the currently selected button.
  6. Close Button Selection
    All we need to do here is creep up the Close button’s containment hierarchy (starting with closeButton.getParent()) until we find a top-level window. If the top-level window is a JDialog we close it (dialog.setVisible(false)); otherwise we do nothing.

And that’s it. What, you may ask, about all the events such as selecting the Color button? Or changing the text in the Color text field? Or incrementing the state of one of the spinners? Well good news! Except for the draw check box, that’s all taken care of by the ColorEditor and the various Feedback subclasses which we have already written and tested. And we don’t need to do anything when the draw check box is clicked; we only have to get or set its value on a synch-left or synch-right operation.

Clearly the biggest part of the task ahead of us is the synch-left/synch-right operations, so let’s start with those.

∎ Synch-Left Operation, Method synchLeft( LinePropertySet set )
Note: this is a method in the inner class PropertiesPanel.

This is the simpler of the two synch- operations. Given a LinePropertySet, for every supported property we have to copy the value of the appropriate component on the right to the property set. Here’s a quick delineation of the properties and associated components:

  • stroke: the spinner model of the JSpinner (strokeSpinner) next to the “Stroke” label, strokeModel.
  • length: the spinner model of the JSpinner (lengthSpinner) next to the “Length” label, lengthModel.
  • spacing: the spinner model of the JSpinner (spacingSpinner) next to the “Spacing” label, spacingModel.
  • color: technically, this is the JTextField (colorField) next to the ColorButton, but we can get the color directly from the ColorEditor, instance variable colorEditor. Recall, by the way, that ColorEditor.getColor returns an Optional which will be empty if the value set by the operator is invalid.
  • draw: the toggle button with the “Draw” label, drawToggle.

The code for the synchLeft method follows.

private void synchLeft( LinePropertySet set )
{
    if ( set.hasDraw() )
        set.setDraw( drawToggle.isSelected() );
    if ( set.hasLength() )
        set.setLength( floatValue( lengthModel ) );
    if ( set.hasSpacing() )
        set.setSpacing( floatValue( spacingModel ) );
    if ( set.hasStroke() )
        set.setStroke( floatValue( strokeModel ) );
    if ( set.hasColor() )
        set.setColor( colorEditor.getColor().orElse( null ) );
}

∎ Synch-Right Operation, Method synchRight( LinePropertySet set )
Note: this is a method in the inner class PropertiesPanel.

This operation is slightly more complicated than synch-left. The component associated with a property (see above), and its descriptor have to be either enabled or disabled depending on whether or not the property is supported for a given LinePropertySet. (By descriptor I mean the component in a given row that describes the property being managed; in all cases except Color the descriptor is a JLabel, for Color it’s the ColorButton.) A list of the descriptor components would look like this:

  • stroke: strokeLabel
  • length: lengthLabel
  • spacing: spacingLabel
  • color: colorButton (in addition to colorField)
  • draw: drawLabel

Here’s the start of the synchRight method; the entire method can be found in the GitHub repository.

private void synchRight( LinePropertySet set )
{
    boolean hasDraw     = set.hasDraw();
    drawToggle.setEnabled( hasDraw );
    drawLabel.setEnabled( hasDraw );
    if ( hasDraw )
        drawToggle.setSelected( set.getDraw() );
    else
        drawToggle.setSelected( false );
    
    boolean hasLength   = set.hasLength();
    lengthSpinner.setEnabled( hasLength );
    lengthLabel.setEnabled( hasLength );
    if ( hasLength )
        lengthModel.setValue( set.getLength() );
    else
        lengthModel.setValue( 0 );
    // ...
}

∎ Event Listeners: Stroke, Length, Spacing JSpinners
Each of the JSpinners in the properties panel need ChangeListeners that will update their associated feedback components when their values change. This takes place in the PropertiesPanel constructor, for example:

public PropertiesPanel()
{
    // ...
    add( strokeLabel );
    add( strokeSpinner );
    add( strokeFB );
    strokeSpinner.addChangeListener( e -> strokeFB.repaint() );
    // ...
}

∎ Event Listeners: Radio Buttons
The radio buttons on the left side of the panel all need ItemListeners that will perform deselect and select processing (see RadioButton Deselection and RadioButton Selection, above). Recall that the inner class PropertiesPanel is an ItemListener, so all we have to do is add it to the radio buttons. The code to do this is in the PropertiesPanel constructor:

public PropertiesPanel()
{
    // ...
    buttonGroup.getButtons()
        .forEach( b -> b.addItemListener( this ) );
}

As an ItemListener, the PropertiesPanel must implement the method itemStateChanged(ItemEvent), which will be called every time a radio button is deselected or selected. Our method just validates the event, then calls synchLeft for a deselection event, or synchRight for a selection event. This is the code for the itemStateChanged method:

@Override
public void itemStateChanged(ItemEvent evt)
{
    Object  source  = evt.getSource();
    if ( source instanceof PRadioButton<?> )
    {
        PRadioButton<?> button  = (PRadioButton<?>)source;
        Object          obj     = button.get();
        if ( obj instanceof LinePropertySet )
        {
            LinePropertySet set = (LinePropertySet)obj;
            if ( evt.getStateChange() == ItemEvent.SELECTED )
                synchRight( set );
            else
                synchLeft( set );
        }
    }
}

∎ Event Listeners: Apply, Reset, Close
Our three control buttons get ActionListeners to do their job (see Apply Button Selection, Reset Button Selection and Close Button Selection, above). Recall that the inner class PropertiesPanel is an ItemListener, so all we have to do is add it to the radio buttons. The code to do this is in the getControlPanel method:

private JPanel getControlPanel()
{
    // ...    
    applyButton.addActionListener( this::applyAction );
    resetButton.addActionListener( this::resetAction );
    closeButton.addActionListener( this::closeAction );
    
    return panel;
}

As discussed above, the Apply operation must first perform a synch-left operation for the selected button, then call the apply methods for all the LinePropertySets managed by the radio buttons on the left side of the panel. Unfortunately the synchLeft method is buried in the PropertiesPanel class, and the Apply button (and the Reset button; see below) doesn’t have direct access to it*. However, the synchLeft method is called every time a radio button is deselected, so one thing we can do is generate a deselection event for the currently selected button. To do that we need to instantiate an ItemEvent, and pass it to all the button’s ItemListeners. You can find the code to do so below, followed by some notes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
private void applyAction( ActionEvent evt )
{
    PRadioButton<LinePropertySet>   selectedButton  = 
        buttonGroup.getSelectedButton();
    ItemEvent   event   = 
        new ItemEvent(
            selectedButton,
            ItemEvent.ITEM_FIRST,
            selectedButton,
            ItemEvent.DESELECTED
        );
    Stream.of( selectedButton.getItemListeners() )
        .forEach( l -> l.itemStateChanged( event ) );
    
    buttonGroup.getButtons().stream()
        .map( b -> b.get() )
        .forEach( s -> s.apply() );
}
  • Lines 3,4: Gets the currently selected radio button.
  • Lines 5-11: Instantiates an ItemEvent object.
    • Line 7: The source of the event; that’s our currently selected radio button.
    • Line 8: The event ID; for us this is a more-or-less arbitrary choice; we just have to make sure that it’s between ItemEvent.ITEM_FIRST and ItemEvent.ITEM_LAST.
    • Line 9: The “item affected by the event”; another arbitrary choice for us, we’ll just say it’s the currently selected radio button.
    • Line 10: Indicates that our event is a deselection event.
  • Line 12: Gets all of the currently selected button’s ItemListeners.
  • Line 13: Passes the ItemEvent we constructed at line 5 to the itemStateChanged method of every ItemListener.
  • Line 15: Gets all the radio buttons.
  • Line 16: Gets the LinePropertySet from each radio button.
  • Line 17: Calles the apply method of each LinePropertySet.

Reset processing is directly analogous to Apply processing (see above):

  1. Get all the LinePropertySets from the radio buttons.
  2. Call the reset method of each LinePropertySet.
  3. For the currently selected button, execute a synch-right operation. For this step we will once again invoke all of the selected button’s ItemListeners*, this time passing a selected event, which will instigate the synch-right.

*It occurs to me that if we save our PropertiesPanel object in an instance variable we could avoid all of this anti-intuitive ItemEvent propagation. This would be a good exercise for the student. And maybe for some refactoring at the start of the next lesson.

Close processing (see above) starts with finding the top-level window in the LinePropertiesPanel containment hierarchy. If the Close button can be pushed, we should certainly find a top-level; if we don’t we’ll throw an exception. If we find one and it’s a JDialog we just have to close it; if it’s not a JDialog we do nothing. Here’s the annotated code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void closeAction( ActionEvent evt )
{
    Object  source  = evt.getSource();
    if ( source instanceof JComponent )
    {
        Container   testObj = ((JComponent)source).getParent();
        while ( !(testObj instanceof Window ) && testObj != null )
            testObj = testObj.getParent();
        if ( testObj == null )
        {
            StringBuilder   bldr    = new StringBuilder()
                .append( "Top-level window of LinePropertiesPanel " )
                .append( "not found; " )
                .append( "source type = " )
                .append( source.getClass().getName() );
            throw new ComponentException( bldr.toString() );
        }
        
        if ( testObj instanceof JDialog )
            ((JDialog)testObj).setVisible( false );
    }
}
  • Lines 3,4: Gets the source of the event, and verifies that it’s a JComponent. Technically the if statement is unnecessary; the source can’t be anything other than a JComponent. The if statement eliminates a compiler warning.
  • Line 6: Gets the parent of the source object.
  • Lines 7,8: Recursively gets the ancestors of the source object until we find a top-level window (testObj instanceof Window) or we run out of ancestors (testObj == null).
  • Line 9: Verifies that we found a top-level window. By the time the Close button is visible and available for selection it almost certainly has a top-level window ancestor; if not, something is seriously wrong.
  • Lines 10-17: Throws an exception if we fail to find a top-level window.
  • Lines 19,20: Closes the top-level window if it’s a JDialog.

∎ Convenience method: getDialog(Window)
Lastly, we have a method purely for the convenience of the user. It’s a class method that will create a JDialog containing a LinePropertiesPanel and returns it; the Window argument may be null. It looks like this; note that it does not make the dialog visible before returning it.

public static JDialog getDialog( Window parent )
{
    final String    title   = "Line Properties Dialog";
    JDialog dialog      = new JDialog( parent, title );
    dialog.setContentPane( new LinePropertiesPanel() );
    dialog.pack();
    return dialog;
}

Summary

On this page we completed developing the LinePropertiesPanel, and we now have test it. We’ll divide testing into two parts: functional testing (the panel responds correctly when we poke at the components), and visual testing (the panel looks right). We’ll start with functional testing. We’ll need some utilities to help us out with that, and since my HTML editor balks when a page gets to big, we’ll start with a page dedicated to the Line Properties Panel Test Dialog, class LPPTestDialog.

Next: Testing the LinePropertiesPanel, Class LPPTestDialog