HowTo design a new feature

From Gephi:Wiki

Jump to: navigation, search

The way how to design features in a modular way can be learnt by following this practical example. it gives important information about workspaces and how to use them to store models.

Let's take the example of the creation of an Annotation functionality in Gephi, with following specifications:

  • An annotation panel would be available in the user interface where users could drop some notes about the graph
  • The panel would belong to the workspace. When users change the workspace, the content of the panel changes.
  • The annotations content should be saved with Gephi project

The following tutorial would give some guidance how to design this new feature in Gephi. Of course, better alternative designs could be found.

Contents

Design

Model–View–Controller

Model–View–Controller (MVC) is a well know architectural pattern adapted for our problem. The design we will come with will be very close from what MVC is.

The different roles:

  • Model: Where the annotation data are, only getters are exposed to the API. There is one model per workspace. The model sends events when it's modified.
  • Controller: Manage the creation of models and expose setters to the API.
  • View: The user interface listen to model changes.

The difference with a traditional MVC model is the presence of a model per workspace. That make sense as we expect to have different data for each workspace. In contrary, the controller is a singleton and would be aware of the current workspace.

The view remains the same and is notified via events when the model change. It also have to know when the workspace changes in order to refresh the model.

The Model

The model stores a simple annotation string:

public interface AnnotationModel {
 
    public static final String ANNOTATION = "annotation";
 
    public String getAnnotation();
 
    public void addPropertyChangeListener(PropertyChangeListener listener);
 
    public void removePropertyChangeListener(PropertyChangeListener listener);
}

The view will add itself as a listener to the model to be notified about updates. The ANNOTATION will be used by the change listener to identify the event.

The Controller

The controller is a singleton and is responsible for managing models. Other modules will ask the controller to give the current model. In Gephi, only one workspace can be active, this is managed by Project API. That means when a setter method is called, the controller will apply it on the currently active model.

public interface AnnotationController {
 
    public AnnotationModel getModel();
 
    public AnnotationModel getModel(Workspace workspace);
 
    public void setAnnotation(String annotation);
}

The getModel() gives the model for the current workspace. An additionnal getModel(Workspace) is useful in case someone would work on all workspaces, for instance to compare annotations and produce statistics. To be completely correct the interface should have a setAnnotation(String annotation, AnnotationModel model to be able to edit values in a different workspace than the active one. As this is unlikely to be useful, we will ignore it.

The View

The view is the user interface and don't need any interface.

Implementation

Model

The implementation of the model has a setter method the controller will use to set the annotation value. Here we use PropertyChangeListener for the event system. You are free to create your own event classes. It's however not recommended to only use ChangeListener and asks the view to completely refresh when anything is changed in the model. That often leads to event loop issues. It's better to explicitly say what changed, even if it takes more time to code.

public final class AnnotationModelImpl {
 
    private final List<PropertyChangeListener> listeners = new ArrayList<PropertyChangeListener>();
    private String annotation = "";
 
    public String getAnnotation() {
        return annotation;
    }
 
    void setAnnotation(String annotation) {
        if (!this.annotation.equals(annotation)) {
            String oldValue = this.annotation;
            this.annotation = annotation;
            firePropertyChangeEvent(AnnotationModel.ANNOTATION, oldValue, annotation);
        }
    }
 
    public void addPropertyChangeListener(PropertyChangeListener listener) {
        if (!listeners.contains(listener)) {
            listeners.add(listener);
        }
    }
 
    public void removePropertyChangeListener(PropertyChangeListener listener) {
        listeners.remove(listener);
    }
 
    private void firePropertyChangeEvent(String propertyName, Object oldValue, Object newValue) {
        PropertyChangeEvent propertyChangeEvent = new PropertyChangeEvent(this, propertyName, oldValue, newValue);
        for (PropertyChangeListener listener : listeners) {
            listener.propertyChange(propertyChangeEvent);
        }
    }
}

Controller

Before looking at the implementation, it's important to know how singleton are registered in an Netbeans Platform application. It's a powerful, yet simple system to let a singleton be accessed by any modules without revealing its implementation. By adding a @ServiceProvider annotation to the controller implementation, it is put in the platform default Lookup. The view will use this to discover the controller.

@ServiceProvicer(service = AnnotationController.class)
public class AnnotationControllerImpl {
 
    private AnnotationModelImpl model;
 
    public TestClas() {
        ProjectController projectController = Lookup.getDefault().lookup(ProjectController.class);
        projectController.addWorkspaceListener(new WorkspaceListener() {
 
            @Override
            public void initialize(Workspace workspace) {
            }
 
            @Override
            public void select(Workspace workspace) {
                model = workspace.getLookup().lookup(AnnotationModelImpl.class);
                if (model == null) {
                    model = new AnnotationModelImpl();
                    workspace.add(model);
                }
            }
 
            @Override
            public void unselect(Workspace workspace) {
            }
 
            @Override
            public void close(Workspace workspace) {
            }
 
            @Override
            public void disable() {
                model = null;
            }
        });
        if (projectController.getCurrentProject() != null) {
            Workspace workspace = projectController.getCurrentWorkspace();
            model = (AnnotationModelImpl) workspace.getLookup().lookup(AnnotationModelImpl.class);
            if (model == null) {
                model = new AnnotationModelImpl();
                workspace.add(model);
            }
        }
    }
 
    public AnnotationModel getModel() {
        return model;
    }
 
    public synchronized AnnotationModel getModel(Workspace workspace) {
        AnnotationModelImpl annotationModel = workspace.getLookup().lookup(AnnotationModelImpl.class);
        if (annotationModel == null) {
            annotationModel = new AnnotationModelImpl();
            workspace.add(annotationModel);
        }
        return annotationModel;
    }
 
    public void setAnnotation(String annotation) {
        if (model != null) {
            model.setAnnotation(annotation);
        }
    }
}

The controller uses WorkspaceListener to be notified of new workspace selected. The controller aim is to maintain up to date his reference to model. Therefore it listens to workspace selection events and get the model, creating it if necessary.

If you look into the Workspace documentation you see that it is a simple container for objects. The model is stored in the workspace's lookup.

At the line if (projectController.getCurrentProject() != null), the controller looks if a project is already active and initialize a model if necessary. We need to do that because the controller will be lazily created, only the first time it is needed. So it's possible the controller is created after a workspace has been created. Ignoring doing that would lead to a null model.

View

Look at this Tutorial to know how to add a module panel in Gephi. You will obtain a TopComponent class. That will be our view. The UI details will not be explained, however what is important is to be linked with the AnnotationModel.

public class AnnotationView extends TopComponent implements PropertyChangeListener {
 
    private AnnotationModel model;
 
    public AnnotationView() {
        AnnotationController annotationController = Lookup.getDefault().lookup(AnnotationController.class);
 
        ProjectController projectController = Lookup.getDefault().lookup(ProjectController.class);
        projectController.addWorkspaceListener(new WorkspaceListener() {
 
            @Override
            public void initialize(Workspace workspace) {
            }
 
            @Override
            public void select(Workspace workspace) {
                model = annotationController.getModel(workspace);
                model.addPropertyChangeListener(this);
                refreshModel();
            }
 
            @Override
            public void unselect(Workspace workspace) {
                if (model != null) {
                    model.removePropertyChangeListener(this);
                }
            }
 
            @Override
            public void close(Workspace workspace) {
            }
 
            @Override
            public void disable() {
                model = null;
                refreshModel();
            }
        });
        if (projectController.getCurrentProject() != null) {
            Workspace workspace = projectController.getCurrentWorkspace();
            model = annotationController.getModel(workspace);
        }
        refreshModel();
    }
 
    private void refreshModel() {
        if (model != null) {
            //Enable controls
        } else {
            //Model is null, disable controls
        }
    }
 
    @Override
    public void propertyChange(PropertyChangeEvent evt) {
        //Model property change
    }
}

The code shows how to use workspace events to refresh the model when the workspace change and listen to events.

Cut this into modules

We obviously designed a new feature that should be put in different modules. Separating the feature into different modules has several goals. The most important is to keep the user interface separated from the business code. For instance, that is essential for the Gephi Toolkit, to be able to ignore user interface modules.

The separation is usually done like this:

  • An API module, with the interfaces and possibly the default implementation
  • A Desktop module, it is the UI

The API module

Create a new module named AnnotationAPI with the codebase org.gephi.annotation.api. Put the AnnotationModel and AnnotationController interfaces in it. This is what other modules want to see, that's why this package will be declared as public. Right-click on the module and select 'Properties'. Go to the 'API Versioning' panel and check the 'org.gephi.annotation.api' package in the 'Public Packages' area. That will allow other modules that depends on AnnotationAPI to use the model and the controller.

Now we will create the default implementation. Create a new package org.gephi.annotation.impl in the same module. You can also put this in a separate module AnnotationImpl. But if it remains simple, it can stay in the API module. Don't put the 'impl' package as a public-package.

The Desktop module

Create a new module named DesktopAnnotation and create your AnnotationView there. Add AnnotationAPI as a dependency.

Serialization

The reason why it's important to save models into workspace is serialization. One can implement a WorkspacePersistenceProvider to define how a model should be serialized into the Gephi project file.

Read also

Personal tools