MPS 2023.3 Help

HowTo -- Adding additional Tools (aka Views)

Adding additional Tools (aka Views)

This document describes how to build a new Tool (For Eclipse users, a Tool in MPS is like a View in Eclipse) for MPS. This can serve as an example to build arbitrary additional tools into MPS. This text emphasizes to aspects of Tool development: how to add a new tool to a language and the menu system, and how to synchronize the view with whatever is currently edited.

In all other ways tools are just Swing UI programs. So for implementing sophisticated views, one needs Java Swing experience.

In this tutorial we will not focus too much on the intricacies of building a tree view with Swing — an endeavour that is surprisingly non-trivial!

The Outline Tool itself

The MPS plugin language supports the definition of new Tools.

Create a new instance of Tool and set the name. You can also give it a different caption and an icon. A default location (bottom, left, right, top) can also be defined.

We now add three fields to the Tool. The first one is used to remember the project, the second one connects the tool to MPS's message bus, and the third one remember the ToolSynchronizer. The ToolSynchronizer updates the outline view in case the active editor changes.

The message bus is IDEA platform's infrastructure for event distribution. We use it for getting notified of selection changes in the currently active editor. More on these two later.

private Project project; private MessageBusConnection messageBusConn; private ToolSynchronizer synchronizer;

We are then ready to implement the three main methods of a Tool: init, dispose and getComponent.

Here is the code (with comments, please read them!):

init(project)->void { this.project = project; } dispose(project)->void { // disconnect from message bus -- connected in getComponent() if ( this.messageBusConn != null ) this.messageBusConn.disconnect(); } getComponent()->JComponent { // create a new JTree with a custom cell renderer (for rendering custom icons) JTree tree = new JTree(new Object[0]); tree.setCellRenderer(new OutlineTreeRenderer(tree.getCellRenderer())); // create a new synchronizer. It needs the tree to notify it of updates. this.synchronizer = new ToolSynchronizer(tree); // connect to the message bus. The synchronizer receives editor change events // and pushes them on to the tree. The synchronizer implements // FileEditorChangeListener to be able to make this work this.messageBusConn = this.project.getMessageBus().connect(); this.messageBusConn.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, this.synchronizer); // we finally return a ScrollPane with the tree in it return new JScrollPane(tree); }

An Action to Open the Tool

Actions are commands that live in the MPS UI. We have to add an action to open the outline view.

Actions live in the plugins aspect of a language. Actions can define keyboard shortcuts, captions and icons, as expected. They also declare action parameters. These define which context needs to be available to be able to execute the action. This determines the presence of the action in the menu, and supports delivering that context into the action itself. In our case the context includes the currently opened project as well as the currently opened file editor.

action context parameters ( always visible = false ) Project project key: PROJECT required FileEditor editor key: FILE_EDITOR required

In the execute method of the action we create and open the Outline tool.

The setOutline method is an ordinary method that lives it the Outline Tool itself. It simply stores the editor that's passed in.

execute(event)->void { tool<Outline> outline = this.project.tool<Outline>; outline.setEditor(this.editor); outline.openTool(true); }

An Action Group

Menu items are added via groups. To be able to add the Open Outline Action to the menu system, we have to define a new group. The group defines its contents (the action, plus a two separators) and it determines where in the menu it should go. Groups live in the plugin aspect as well.

group OutlineGroup is popup: false contents <---> OpenOutlineAction <---> modifications add to Tools at position customTools

Managing the Tool Lifecycle

The tool must play nicely with the rest of MPS. It has to listen for a number of events and react properly. There are two listeners dedicated to this task. The EditorActivationListener tracks which of the potentially many open editors is currently active, since the outline view has to show the outline for whatever editor is currently used by the user. It is also responsible to hooking up further listeners that deal with the selection status and model changes (see below). The ModelLifecycleListener tracks lifecycle events for the model that is edited by the currently active editor. The model may be replaced while it is edited, for example, by a revert changes VCS operation.

Editor Activation and Focus

The EditorActivationListener tracks which of the potentially many open editors is currently active. It is instantiated and hooked up to the MPS message bus by the tool as it is created by the following code (already shown above):

this.messageBusConn = this.project.getMessageBus().connect(); this.messageBusConn.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, this.synchronizer);

In its constructor, it remember the outline tree. Then it sets up a new outline tree selection listener that listens to selection changes made by the user in the tree itself.

this.outlineSelectionListener = new OutlineSelectionListener(this); tree.addTreeSelectionListener(this.outlineSelectionListener);

It then sets up a listener to keep track of the model lifecycle (load, unload, replace) and it hooks up with the events collector (both explained below).

modelLifecycleListener = new ModelLifecycleListener(tree); eventsCollector = new ModelChangeListener(tree);

The EditorActivationListener implements FileEditorManagerListener, so it has to implement the following three methods:

void fileOpened(FileEditorManager p0, VirtualFile p1); void fileClosed(FileEditorManager p0, VirtualFile p1); void selectionChanged(FileEditorManagerEvent p0);

In our case we are interested in the selectionChanged event, since we'll have to clean up and hook up all kinds of other listeners. Here is the implementation.

public void selectionChanged(FileEditorManagerEvent event) { // read action is required since we access the model read action { FileEditor oldEditor = event.getOldEditor(); // grab the old editor and clean it up if there is one if (oldEditor != null) { this.cleanupOldEditor(oldEditor); } // call a helper method that sets up the new editor this.newEditorActivated(event.getNewEditor()); } }

The cleanupOldEditor method removes existing listeners from the old, now inactive editor:

private void cleanupOldEditor(FileEditor oldEditor) { // Downcast from IDEA level to MPS specifics and // grab the NodeEditor component IEditor oldNodeEditor = ((MPSFileNodeEditor) oldEditor).getNodeEditor(); if (oldNodeEditor != null && this.editorSelectionListener != null) { // remove the selection listener from the old editor oldNodeEditor.getCurrentEditorComponent().getSelectionManager(). removeSelectionListener(this.editorSelectionListener); // grab the descriptor of the model edited by the old editor // and remove the model listener (cleanup!) SModelDescriptor descriptor = oldNodeEditor.getEditorContext().getModel(). getModelDescriptor(); descriptor.removeModelListener(modelLifecycleListener); // remove the model edited by the old editor from the events collector // ...we are not interested anymore. eventsCollector.remove(descriptor); } }

The next method hooks up the infrastructure to the newly selected editor and its underlying model. Notice how we use SNodePointer whenever we keep references to nodes. This acts as a proxy and deals with resolving the node in case of a model is replaced and contains a "weak reference" to the actual node, so it can be garbage collected if the model is unloaded. This is important to avoid memory leaks!

public void newEditorActivated(FileEditor fileEditor) { if (fileEditor != null) { // remember the current editor this.currentEditor = ((MPSFileNodeEditor) fileEditor); // grab the root node of that new editor... SNode rootNode = this.currentEditor.getNodeEditor(). getCurrentlyEditedNode().getNode(); // ...wrap it in an SNodePointer... SNodePointer treeRoot = new SNodePointer(rootNode); // and create a new outline tree model OutlineModel model = new OutlineModel(treeRoot); tree.setModel(model); // create a new selection listener and hook it up // with the newly selected editor this.editorSelectionListener = new EditorSelectionListener(tree, outlineSelectionListener); SelectionManager selectionManager = this.currentEditor.getNodeEditor(). getCurrentEditorComponent().getSelectionManager(); selectionManager.addSelectionListener(this.editorSelectionListener); // This is needed to detect reloading of a model ((MPSFileNodeEditor) fileEditor).getNodeEditor(). getEditorContext().getModel().getModelDescriptor(). addModelListener(modelLifecycleListener); eventsCollector.add(this.currentEditor.getNodeEditor(). getEditorContext().getModel().getModelDescriptor()); } else { tree.setModel(new OutlineModel(null)); } }

Tracking the Model Lifecycle

The ModelLifecycleListener extends the SModelAdapter class provided by MPS. We are interested in the model replacement and hence overload the modelReplaced method. It is called whenever the currently held model is replaced by a new one, e.g. during a VCS rever operation.

In the implementation, we create a new new tree model for the same root. While this code looks a bit nonsensical, note that we use an SNodePointer internally which automatically re-resolves the proxied node in the new model.

We also add ourselves as a listener to the new model's descriptor to be notified of subseqent model replacement events.

@Override public void modelReplaced(SModelDescriptor descriptor) { tree.setModel(new OutlineModel(((OutlineModel) tree.getModel()).getRealRoot())); descriptor.addModelListener(this); }

Synchronizing Node Selections

Tracking Editor Selection

This one updates the selection (and expansion status) of the tree as the selected node in the currently active editor changes. We have already made sure (above) that the outline view's tree is synchronized with the currently active editor.

The class extends MPS' SingularSelectionListenerAdapter because we are only interested in single node selections.

We overwrite the selectionChangedTo method in the following way:

protected void selectionChangedTo(EditorComponent component, SingularSelection selection) { // do nothing is disabled -- prevents cyclic, never ending updates if (disabled) { return; } // read action, because we access the model read action { // gran the current selection SNode selectedNode = selection.getSelectedNodes().get(0); // ... only if it has changed... if (selectedNode != lastSelection) { lastSelection = selectedNode; // disable the tree selection listener, once again to prevent cyclic // never ending updates treeSelectionListener.disable(); // select the actual node in the tree tree.setSelectionPath(((OutlineModel) tree.getModel()). gtPathTo(selectedNode)); treeSelectionListener.enable(); } } }

Tracking Changes in the Model Structure

The tree structure in the outline view has to be updated if nodes are added, changed (renamed), moved or deleted in the editor. Tree change events can be quite granular, and to avoid overhead, MPS collects them into batches related to more coarse-grained commands. By using MPS' EventsCollector base class, we can get notified when a significant batch of events has happened, and then inspect the event list for those that we are interested in using a visitor.

The ModelChangeListener performs this task. To do so, we have to implement the eventsHappened method. We get a list of events, and use a an inner class that extends SModelEventVisitorAdapter to visit the events and react to those that we are interested in.

protected void eventsHappened(List<SModelEvent> list) { super.eventsHappened(list); foreach evt in list { evt.accept(new ModelChangeVisitor()); } }

The ModelChangeVisitor inner class, which acts as the visitor to notify the tree, overrides visitPropertyEvent to find out about nodes whose properties have changed in the current batch. It then notifies all the listeners of the tree model.

public void visitPropertyEvent(SModelPropertyEvent event) { OutlineModel outlineModel = ((OutlineModel) tree.getModel()); foreach l in outlineModel.getListeners() { l.treeNodesChanged(new TreeModelEvent(this, outlineModel.getPathTo(event.getNode()))); } }

It also overwrites visitChildEvent to get notified of child additions/deletions of nodes. Except that the API in JTree is a bit annoying, the following commented code should be clear about what it does:

@Override public void visitChildEvent(SModelChildEvent event) { // grab the model OutlineModel outlineModel = ((OutlineModel) tree.getModel()); // we need the following arrays later for the JTree API Object[] child = new Object[]{event.getChild()}; int[] childIndex = new int[]{event.getChildIndex()}; // we create a tree path to the parent notify all listeners // of adding or removing children TreePath path = outlineModel.getPathTo(event.getParent()); if (path == null) { return; } // notify the tree model's listeners about what happened foreach l in outlineModel.getListeners() { if (event.isAdded()) { l.treeNodesInserted(new TreeModelEvent(this, path, childIndex, child)); } else if (event.isRemoved()) { l.treeNodesRemoved(new TreeModelEvent(this, path, childIndex, child)); } } }

The way back: Tracking Tree Selection

Tracking a JTree's selection happens by implementing Swing's TreeSelectionListener and overwriting it's valueChanged method the following way:

public void valueChanged(TreeSelectionEvent event) { // don't do anything if disabled --- preventing cyclic updates! if (!(disableEditorUpdate)) { JTree tree = ((JTree) event.getSource()); if (editorActivationListener.currentEditor != null && tree.getLastSelectedPathComponent() instanceof SNodePointer) { // grab the selected treee node SNodePointer pointer = ((SNodePointer) tree. getLastSelectedPathComponent()); // disable the editor selection listener to prevent // cyclic, never ending updates editorActivationListener.editorSelectionListener.disable(); // update the selection in the editor editorActivationListener.currentEditor.getNodeEditor(). getCurrentEditorComponent().selectNode(pointer.getNode()); editorActivationListener.editorSelectionListener.enable(); } } }

Swing's Artifacts: Tree Model and Renderer

In this section I want to describe a couple of interesting MPS-specific aspects of the implementation of the Swing artifacts.

Tree Cell Renderer

The tree cell renderer is responsible for rendering the cells in the tree. It uses the getPresentation method on the nodes, and the IconManager to get (a cached version of) the icon for the respective node.

public Component getTreeCellRendererComponent(JTree tree, Object object, boolean selected, boolean expanded, boolean leaf, int row, boolean hasFocus) { if (object instanceof SNodePointer) { string presentation; DefaultTreeCellRenderer component; read action { SNode node = ((SNodePointer) object).getNode(); presentation = node.getPresentation(); component = ((DefaultTreeCellRenderer) renderer.getTreeCellRendererComponent( tree, presentation, selected, expanded, leaf, row, hasFocus)); component.setIcon(IconManager.getIconFor(node)); } } else { return renderer.getTreeCellRendererComponent(tree, object, selected, expanded, leaf, row, hasFocus); } }

Tree Model

The tree model is interesting since we include only those children in the tree node whose concept has been annotated with the ShowConceptInOutlineAttribute attribute. It is stored in the storeInOutline role.

In the tree model, we have to filter the children of a node for the presence of this attribute. Here is the respective helper method:

private List findAllChildrenWithAttribute(SNodePointer pointer) { List result = new ArrayList(); SNode node = pointer.getNode(); if (node == null) { return new ArrayList(); } foreach child in node.getChildren() { SNode attribute = AttributeOperations.getNodeAttribute( child.getConceptDeclarationNode(), "showInOutline"); if (attribute != null) { result.add(child); } } return result; }
Last modified: 07 March 2024