Introduction to the ILcyCustomizerPanel interface

The ILcyCustomizerPanel interface defines a contract for UI elements to customize a certain object. The panel offers the ILcyCustomizerPanel.setObject method to specify which object must be customized. Once the object is set on the customizer panel, it is the responsibility of the panel to:

  • Listen for changes made to the object:_ whenever the object changes, the panel must update its UI to match the state of the object.

  • Indicate when the user made changes to the panel: whenever the user makes a change in the UI of the panel, the panel must indicate that changes are pending. It can do so by firing a PropertyChangeEvent for the changesPending property.

  • Apply the UI changes onto the object: when the ILcyCustomizerPanel.applyChanges method is called, the panel must apply the current state of the UI onto the object. For example, the Object Properties panel action calls this method whenever the panel indicates that changes are pending. Consequently, when the user makes a change in the panel, the changes are immediately applied and visible on the map.

You can use the same ILcyCustomizerPanel instance for several objects by calling ILcyCustomizerPanel.setObject multiple times, once for each object. The panel must always update itself to match the state of the last object set.

Typically those customizer panel instances are created through a ILcyCustomizerPanelFactory. That interface defines a simple factory class that offers methods to:

  • Check whether it can create a customizer panel for a certain object

  • Create the customizer panel for a certain object

Goal

In this tutorial, we implement an ILcyCustomizerPanel for a waypoint domain object. The waypoint object is an ILcdDataObject with two properties:

The waypoint data format is introduced in the Support a custom vector format tutorial.

You can check that tutorial for all details about the format, but for this tutorial it is sufficient to know that those waypoints are data objects with two properties.

We will create a panel that contains:

  • A text field that allows users to change the identifier

  • A text field that allows users to change the location of the waypoint. This text field accepts only valid coordinates and provides visual feedback to the user when the input string is incorrect.

  • A text field that allows the user to change the height of the waypoint. This text field only accepts distances: a number and a unit.

waypoint customizer panel

Creating the UI

As a first step, we create a Swing UI that contains the three text fields.

Because the waypoint customizer panel is designed to edit waypoints, which are elements contained in an ILcdModel, we can start from the ALcyDomainObjectCustomizerPanel class.

In LuciadLightspeed, domain objects are stored as elements in an ILcdModel.

This ALcyDomainObjectCustomizerPanel class is an extension from javax.swing.JPanel. It is an abstract base class that implements part of the ILcyCustomizerPanel interface for panels operating on domain objects. To benefit from that functionality, our WayPointCustomizerPanel extends from this class instead of implementing the interface directly:

public class WayPointCustomizerPanel extends ALcyDomainObjectCustomizerPanel {
}

We now create and add three text fields to this JPanel. Although we could use standard Swing code for this, we will use some utility classes available in the Lucy API and sample code:

private ValidatingTextField fIdentifierField;
private ValidatingTextField fLocationField;
private ValidatingTextField fHeightField;

private void initUI(ILcyLucyEnv aLucyEnv) {
  fIdentifierField = new ValidatingTextField(new StringFormat(), aLucyEnv);
  fLocationField = new ValidatingTextField(aLucyEnv.getDefaultLonLatPointFormat(), aLucyEnv);
  fHeightField = new ValidatingTextField(aLucyEnv.getDefaultAltitudeFormat(), aLucyEnv);

  TLcyTwoColumnLayoutBuilder.newBuilder()
                            .addTitledSeparator("Way point")
                            .row()
                            .columnOne(new JLabel("Identifier"), fIdentifierField)
                            .build()
                            .row()
                            .columnOne(new JLabel("Location"), fLocationField)
                            .build()
                            .row()
                            .columnOne(new JLabel("Height"), fHeightField)
                            .build()
                            .populate(this);
}
  • The samples.lucy.util.ValidatingTextField is a JTextField that also performs validation. When the user enters an invalid input string, the background color of the text field will become red.

  • The TLcyTwoColumnLayoutBuilder is a builder class that makes it easy to create a layout of labels and text fields. Consult the class Javadoc for an image.

You are completely free to choose how you create your UI. There is no need to use the TLcyTwoColumnLayoutBuilder if you prefer to use another mechanism, such as a standard BorderLayout.

Setting the values in the text fields

The text fields in our UI must display the values of the waypoint. As documented in the class Javadoc of ALcyDomainObjectCustomizerPanel, this base class calls the abstract method updateCustomizerPanelFromObject each time the domain object is changed or a new domain object is installed on the panel.

It is in the implementation of this method that we read the values from our domain object, and set them on our text fields:

@Override
protected void updateCustomizerPanelFromObject(boolean aPanelEditable) {
  fIdentifierField.setEditable(aPanelEditable);
  fLocationField.setEditable(aPanelEditable);
  fHeightField.setEditable(aPanelEditable);

    ILcdDataObject waypoint = (ILcdDataObject) getDomainObject();
    if (waypoint != null) {
      //Take a read lock so that we can safely read the values from the domain object
      try (TLcdLockUtil.Lock autoUnlock = TLcdLockUtil.readLock(getModel())) {
        fIdentifierField.setValue(waypoint.getValue("identifier"));
        TLcdLonLatHeightPoint location = (TLcdLonLatHeightPoint) ALcdShape.fromDomainObject(waypoint);
        fLocationField.setValue(location);
        fHeightField.setValue(location.getZ());
      }
    } else {
      fIdentifierField.setValue("");
      fLocationField.setValue(null);
      fHeightField.setValue(0);
    }
}

Applying the changes

The panel is also responsible for updating the domain object when somebody calls the ILcyCustomizerPanel.applyChanges method on it. The ALcyDomainObjectCustomizerPanel class already implements this method, and delegates to the abstract method applyChangesImpl.

In this method, we read the values from the text fields and apply them to the domain object:

@Override
protected boolean applyChangesImpl() {
  ILcdDataObject waypoint = (ILcdDataObject) getDomainObject();
  if (waypoint != null) {
    ILcdModel model = getModel();
    //Changing a model element requires a write lock
    try (TLcdLockUtil.Lock autoUnlock = TLcdLockUtil.writeLock(model)) {
      waypoint.setValue("identifier", fIdentifierField.getValue());
      TLcdLonLatHeightPoint location = (TLcdLonLatHeightPoint) ALcdShape.fromDomainObject(waypoint);
      ILcdPoint updatedLocation = (ILcdPoint) fLocationField.getValue();
      double height = (double) fHeightField.getValue();
      location.move3D(updatedLocation.getX(), updatedLocation.getY(), height);
      model.elementChanged(waypoint, ILcdModel.FIRE_LATER);
    } finally {
      model.fireCollectedModelChanges();
    }
  }
  return true;
}

Gluing it all together: keeping the UI and the domain object in-sync

The introduction mentioned the responsibilities of a customizer panel once an object was installed on it:

  • Listen for changes made to the object and update the panel accordingly: the ALcyDomainObjectCustomizerPanel takes care of that for us. It listens to the ILcdModel containing the domain object, and calls updateCustomizerPanelFromObject whenever the domain object changes. In our implementation of this method, we update the UI.

  • Apply the UI changes to the object when applyChanges is called: our implementation of applyChangesImpl reads the values from the UI and applies them to the domain object.

  • Indicate when the user made changes to the panel: this is not yet done. Our customizer panel needs to change its changesPending property to true and fire an event.

A first step is to detect when the values in the text fields are changed. For this we add a listener to the text fields. You can do that in the constructor after the fields have been created, for example:

The constructor requires us to call the super constructor with a filter and a name:

public WayPointCustomizerPanel(ILcyLucyEnv aLucyEnv) {
  super(WAYPOINT_DOMAIN_OBJECT_FILTER, "Way points");
  //Create the text fields and add them to this panel
  initUI(aLucyEnv);

}

The filter must accept only waypoint domain objects, as that is the only object our panel can deal with:

private static final ILcdFilter WAYPOINT_DOMAIN_OBJECT_FILTER = new ILcdFilter() {
  @Override
  public boolean accept(Object aObject) {
    if (aObject instanceof TLcyDomainObjectContext) {
      TLcyDomainObjectContext context = (TLcyDomainObjectContext) aObject;
      //Check if the model of the domain object is a waypoint model
      return "CWP".equals(context.getModel().getModelDescriptor().getTypeName());
    }
    return false;
  }
};

Now we create and install the listener on the text fields. The ValidatingTextField fires a PropertyChangeEvent for the value property each time a new, valid value is entered and confirmed by the user. Our listener listens for that property:

//Install the listener on the text fields
fIdentifierField.addPropertyChangeListener("value", textFieldListener);
fLocationField.addPropertyChangeListener("value", textFieldListener);
fHeightField.addPropertyChangeListener("value", textFieldListener);

The listener itself changes the changesPending property of the panel:

//Create a listener to detect changes made by the user in the text fields
PropertyChangeListener textFieldListener = evt -> {
  if (!fUpdatingUI) {
    setChangesPending(true);
  }
};

As you can see in the code above, the listener first checks a boolean flag before calling setChangesPending. We do this to prevent a loop in the following scenario:

  • The user modifies the domain object on the map

  • The ALcyDomainObjectCustomizerPanel detects this change, and calls updateCustomizerPanelFromObject.

  • In this method, the panel updates the contents of the text fields. This generates an event.

  • The text field listener picks up this event. However, at this point the UI is in-sync with the domain object, so there are no pending changes.

To prevent that loop and allow the text field listener to distinguish between "changes in the text fields made by the user" and "changes in the text field made by our code updating the panel", we adjust the updateCustomizerPanelFromObject to change the state of that flag before and after updating the text fields:

@Override
protected void updateCustomizerPanelFromObject(boolean aPanelEditable) {
  fIdentifierField.setEditable(aPanelEditable);
  fLocationField.setEditable(aPanelEditable);
  fHeightField.setEditable(aPanelEditable);

  boolean old = fUpdatingUI;
  try {
    //Switch the flag to indicate we are currently updating the panel
    //and the changes to the text fields are not made by the user
    fUpdatingUI = true;
    ILcdDataObject waypoint = (ILcdDataObject) getDomainObject();
    if (waypoint != null) {
      //Take a read lock so that we can safely read the values from the domain object
      try (TLcdLockUtil.Lock autoUnlock = TLcdLockUtil.readLock(getModel())) {
        fIdentifierField.setValue(waypoint.getValue("identifier"));
        TLcdLonLatHeightPoint location = (TLcdLonLatHeightPoint) ALcdShape.fromDomainObject(waypoint);
        fLocationField.setValue(location);
        fHeightField.setValue(location.getZ());
      }
    } else {
      fIdentifierField.setValue("");
      fLocationField.setValue(null);
      fHeightField.setValue(0);
    }
  } finally {
    //Restore the state of the flag
    fUpdatingUI = old;
  }
}

The flag itself is a boolean field:

/**
 * This boolean field is used to avoid loops:
 * <ul>
 *   <li>When the user updates the waypoint on the map,
 *   the customizer panel will update the contents of the text fields.</li>
 *   <li>The listener attached to the text fields would detect this change,
 *   and indicate that the user made a change in the text fields (which isn't the case).</li>
 * </ul>
 * This boolean is used to indicate when the customizer panel itself is updating the text fields,
 * allowing the text field listener to distinguish between user-made changes
 * and changes made by the panel itself.
 */
boolean fUpdatingUI = false;

Keeping the point and distance format in-sync

In the UI, we used the point and distance format from the Lucy back-end for formatting our location and altitude. The Lucy UI and API allow changing this format at runtime.

We always want to use the current version of this format, so we need to update our UI when this format changes at the back-end. For this, we add listeners to the back-end in the constructor:

aLucyEnv.addPropertyChangeListener(new PointFormatListener(this));
aLucyEnv.addPropertyChangeListener(new AltitudeFormatListener(this));

Those listeners are attached to the Lucy back-end, which remains alive as long as our application is running. To prevent a memory leak, we use an ALcdWeakPropertyChangeListener, which only keeps a weak reference to our panel.

See the class Javadoc of ALcdWeakModelListener for more details on weak listeners and the type of memory leak they prevent.

/**
 * The location field should be formatted using the point format exposed on the Lucy back-end.
 * When this format changes, the UI must be updated.
 */
private static class PointFormatListener extends
                                         ALcdWeakPropertyChangeListener<WayPointCustomizerPanel> {

  private PointFormatListener(WayPointCustomizerPanel aObjectToModify) {
    super(aObjectToModify);
  }

  @Override
  protected void propertyChangeImpl(WayPointCustomizerPanel aWayPointCustomizerPanel, PropertyChangeEvent aPropertyChangeEvent) {
    String propertyName = aPropertyChangeEvent.getPropertyName();
    if ("defaultLonLatPointFormat".equals(propertyName)) {
      aWayPointCustomizerPanel.updatePointFormat((Format) aPropertyChangeEvent.getNewValue());
    }
  }
}

/**
 * The altitude field should be formatted using the altitude format exposed on the Lucy back-end.
 * When this format changes, the UI must be updated
 */
private static class AltitudeFormatListener extends ALcdWeakPropertyChangeListener<WayPointCustomizerPanel> {
  private AltitudeFormatListener(WayPointCustomizerPanel aObjectToModify) {
    super(aObjectToModify);
  }

  @Override
  protected void propertyChangeImpl(WayPointCustomizerPanel aWayPointCustomizerPanel, PropertyChangeEvent aPropertyChangeEvent) {
    String propertyName = aPropertyChangeEvent.getPropertyName();
    if ("defaultAltitudeFormat".equals(propertyName) || "defaultUserAltitudeUnit".equals(propertyName)) {
      aWayPointCustomizerPanel.updateAltitudeFormat(((ILcyLucyEnv) aPropertyChangeEvent.getSource()).getDefaultAltitudeFormat());
    }
  }
}

When the listener detects a change in the format, it needs to update the text fields. Similar to what we did in the updateCustomizerPanelFromObject method, we switch the state of the boolean flag to indicate that the change to the text fields is not made by the user.

private void updatePointFormat(Format aPointFormat) {
  boolean old = fUpdatingUI;
  try {
    fUpdatingUI = true;
    fLocationField.setFormat(aPointFormat, fLocationField.getValue());
  } finally {
    fUpdatingUI = old;
  }
}

private void updateAltitudeFormat(Format aAltitudeFormat) {
  boolean old = fUpdatingUI;
  try {
    fUpdatingUI = true;
    fHeightField.setFormat(aAltitudeFormat, fHeightField.getValue());
  } finally {
    fUpdatingUI = old;
  }
}

Using the customizer panel

The customizer panel we created does not contain any buttons to apply the changes. This is intentional. The normal usage of a customizer panel is:

  • Create a container to contain a customizer panel.

  • Decide how to apply the changes made in the customizer panel, for example:

    • Add an Apply button underneath the customizer panel.

    • Use a listener for the changesPending property and automatically apply changes as soon as the user makes them.

    If the panel already contained an Apply button, the container would have to know that this button is present and would no longer be able to choose when to apply the changes. This limits the possible usages of the panel.

For the same reason, a customizer panel should not use a JScrollPane. If the customizer panels are shown in a location with limited space, the container should decorate the customizer panel with a JScrollPane.

  • Create the customizer panel —  typically by using an ILcyCustomizerPanelFactory — , call setObject on it, and display it in the container.

  • Once the customizer panel is no longer needed, remove it from the container and call setObject(null) on it.

See the Adding support for custom editable data to a Lightspeed view tutorial for plugging in your own customizer panel for your custom data format.

Full code of the customizer panel

package com.luciad.lucy.addons.tutorial.editabledata.model;

import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.text.Format;

import javax.swing.JLabel;

import com.luciad.datamodel.ILcdDataObject;
import com.luciad.lucy.ILcyLucyEnv;
import com.luciad.lucy.gui.TLcyTwoColumnLayoutBuilder;
import com.luciad.lucy.gui.customizer.ALcyDomainObjectCustomizerPanel;
import com.luciad.lucy.util.context.TLcyDomainObjectContext;
import com.luciad.model.ILcdModel;
import com.luciad.shape.ALcdShape;
import com.luciad.shape.ILcdPoint;
import com.luciad.shape.shape3D.TLcdLonLatHeightPoint;
import com.luciad.util.ALcdWeakPropertyChangeListener;
import com.luciad.util.ILcdFilter;
import com.luciad.util.concurrent.TLcdLockUtil;

import samples.lucy.text.StringFormat;
import samples.lucy.util.ValidatingTextField;

public class WayPointCustomizerPanel extends ALcyDomainObjectCustomizerPanel {

  private static final ILcdFilter WAYPOINT_DOMAIN_OBJECT_FILTER = new ILcdFilter() {
    @Override
    public boolean accept(Object aObject) {
      if (aObject instanceof TLcyDomainObjectContext) {
        TLcyDomainObjectContext context = (TLcyDomainObjectContext) aObject;
        //Check if the model of the domain object is a waypoint model
        return "CWP".equals(context.getModel().getModelDescriptor().getTypeName());
      }
      return false;
    }
  };

  private ValidatingTextField fIdentifierField;
  private ValidatingTextField fLocationField;
  private ValidatingTextField fHeightField;


  /**
   * This boolean field is used to avoid loops:
   * <ul>
   *   <li>When the user updates the waypoint on the map,
   *   the customizer panel will update the contents of the text fields.</li>
   *   <li>The listener attached to the text fields would detect this change,
   *   and indicate that the user made a change in the text fields (which isn't the case).</li>
   * </ul>
   * This boolean is used to indicate when the customizer panel itself is updating the text fields,
   * allowing the text field listener to distinguish between user-made changes
   * and changes made by the panel itself.
   */
  boolean fUpdatingUI = false;

  public WayPointCustomizerPanel(ILcyLucyEnv aLucyEnv) {
    super(WAYPOINT_DOMAIN_OBJECT_FILTER, "Way points");
    //Create the text fields and add them to this panel
    initUI(aLucyEnv);

    //Create a listener to detect changes made by the user in the text fields
    PropertyChangeListener textFieldListener = evt -> {
      if (!fUpdatingUI) {
        setChangesPending(true);
      }
    };

    //Install the listener on the text fields
    fIdentifierField.addPropertyChangeListener("value", textFieldListener);
    fLocationField.addPropertyChangeListener("value", textFieldListener);
    fHeightField.addPropertyChangeListener("value", textFieldListener);
    aLucyEnv.addPropertyChangeListener(new PointFormatListener(this));
    aLucyEnv.addPropertyChangeListener(new AltitudeFormatListener(this));
  }

  private void initUI(ILcyLucyEnv aLucyEnv) {
    fIdentifierField = new ValidatingTextField(new StringFormat(), aLucyEnv);
    fLocationField = new ValidatingTextField(aLucyEnv.getDefaultLonLatPointFormat(), aLucyEnv);
    fHeightField = new ValidatingTextField(aLucyEnv.getDefaultAltitudeFormat(), aLucyEnv);

    TLcyTwoColumnLayoutBuilder.newBuilder()
                              .addTitledSeparator("Way point")
                              .row()
                              .columnOne(new JLabel("Identifier"), fIdentifierField)
                              .build()
                              .row()
                              .columnOne(new JLabel("Location"), fLocationField)
                              .build()
                              .row()
                              .columnOne(new JLabel("Height"), fHeightField)
                              .build()
                              .populate(this);
  }

  @Override
  protected void updateCustomizerPanelFromObject(boolean aPanelEditable) {
    fIdentifierField.setEditable(aPanelEditable);
    fLocationField.setEditable(aPanelEditable);
    fHeightField.setEditable(aPanelEditable);

    boolean old = fUpdatingUI;
    try {
      //Switch the flag to indicate we are currently updating the panel
      //and the changes to the text fields are not made by the user
      fUpdatingUI = true;
      ILcdDataObject waypoint = (ILcdDataObject) getDomainObject();
      if (waypoint != null) {
        //Take a read lock so that we can safely read the values from the domain object
        try (TLcdLockUtil.Lock autoUnlock = TLcdLockUtil.readLock(getModel())) {
          fIdentifierField.setValue(waypoint.getValue("identifier"));
          TLcdLonLatHeightPoint location = (TLcdLonLatHeightPoint) ALcdShape.fromDomainObject(waypoint);
          fLocationField.setValue(location);
          fHeightField.setValue(location.getZ());
        }
      } else {
        fIdentifierField.setValue("");
        fLocationField.setValue(null);
        fHeightField.setValue(0);
      }
    } finally {
      //Restore the state of the flag
      fUpdatingUI = old;
    }
  }

  @Override
  protected boolean applyChangesImpl() {
    ILcdDataObject waypoint = (ILcdDataObject) getDomainObject();
    if (waypoint != null) {
      ILcdModel model = getModel();
      //Changing a model element requires a write lock
      try (TLcdLockUtil.Lock autoUnlock = TLcdLockUtil.writeLock(model)) {
        waypoint.setValue("identifier", fIdentifierField.getValue());
        TLcdLonLatHeightPoint location = (TLcdLonLatHeightPoint) ALcdShape.fromDomainObject(waypoint);
        ILcdPoint updatedLocation = (ILcdPoint) fLocationField.getValue();
        double height = (double) fHeightField.getValue();
        location.move3D(updatedLocation.getX(), updatedLocation.getY(), height);
        model.elementChanged(waypoint, ILcdModel.FIRE_LATER);
      } finally {
        model.fireCollectedModelChanges();
      }
    }
    return true;
  }

  private void updatePointFormat(Format aPointFormat) {
    boolean old = fUpdatingUI;
    try {
      fUpdatingUI = true;
      fLocationField.setFormat(aPointFormat, fLocationField.getValue());
    } finally {
      fUpdatingUI = old;
    }
  }

  private void updateAltitudeFormat(Format aAltitudeFormat) {
    boolean old = fUpdatingUI;
    try {
      fUpdatingUI = true;
      fHeightField.setFormat(aAltitudeFormat, fHeightField.getValue());
    } finally {
      fUpdatingUI = old;
    }
  }


  /**
   * The location field should be formatted using the point format exposed on the Lucy back-end.
   * When this format changes, the UI must be updated.
   */
  private static class PointFormatListener extends
                                           ALcdWeakPropertyChangeListener<WayPointCustomizerPanel> {

    private PointFormatListener(WayPointCustomizerPanel aObjectToModify) {
      super(aObjectToModify);
    }

    @Override
    protected void propertyChangeImpl(WayPointCustomizerPanel aWayPointCustomizerPanel, PropertyChangeEvent aPropertyChangeEvent) {
      String propertyName = aPropertyChangeEvent.getPropertyName();
      if ("defaultLonLatPointFormat".equals(propertyName)) {
        aWayPointCustomizerPanel.updatePointFormat((Format) aPropertyChangeEvent.getNewValue());
      }
    }
  }

  /**
   * The altitude field should be formatted using the altitude format exposed on the Lucy back-end.
   * When this format changes, the UI must be updated
   */
  private static class AltitudeFormatListener extends ALcdWeakPropertyChangeListener<WayPointCustomizerPanel> {
    private AltitudeFormatListener(WayPointCustomizerPanel aObjectToModify) {
      super(aObjectToModify);
    }

    @Override
    protected void propertyChangeImpl(WayPointCustomizerPanel aWayPointCustomizerPanel, PropertyChangeEvent aPropertyChangeEvent) {
      String propertyName = aPropertyChangeEvent.getPropertyName();
      if ("defaultAltitudeFormat".equals(propertyName) || "defaultUserAltitudeUnit".equals(propertyName)) {
        aWayPointCustomizerPanel.updateAltitudeFormat(((ILcyLucyEnv) aPropertyChangeEvent.getSource()).getDefaultAltitudeFormat());
      }
    }
  }

}