Friday, December 13, 2013

Split Editors: Anatomy of a new Feature


Finally, we're able to start moving the Eclipse IDE beyond what 3.x could do, showing some real pay-off for all the pain (for you and us). First up is the infamous Bug 8009, 11 years old and highly desired but never able to be implemented in the 3.x code base.

Lars has (again) beat me to the punch as far as the announcement goes so I'll take a different track; how did we go from the idea to the finished feature ?

Step 1: Design and Mockups


We started by making a variety of informal stabs at hacking the functionality, arriving in the end at the conclusion that in order to proceed we needed to allow a mechanism for  an MPart to contain a sub-structure (sashes and other parts...). This part is all about testing the scope of the feature and learning what we really need.

This eventually lead to the extension of the model with a new element MCompositePart, giving us a formal definition of a part that can contain other parts.

Step 2: Functional Prototype


Once the MCompositePart made it into the model it was time to hack up a prototype of the feature, making sure that it performs all the operations necessary to expose the new functionality. My favorite technique for this type of work (especially when it's eventually aimed at the IDE rather than pure e4) is to just make a new project based off the 'Hello World, Command' template. Then I work on the command's handler code until I have a 'command' that can do what I want. In this case I ended up with the following (Kodos to the Orion team; my code snippets use an Embedded Orion Editor):

public Object execute(ExecutionEvent event) throws ExecutionException {
 window = HandlerUtil.getActiveWorkbenchWindowChecked(event);
 MApplication theApp = (MApplication) window.getService(MApplication.class);
 MTrimmedWindow win1 = (MTrimmedWindow) theApp.getChildren().get(0);

 // Get services
 ms =  win1.getContext().get(EModelService.class);
 ps = win1.getContext().get(EPartService.class);
 pe = win1.getContext().get(IPresentationEngine.class);
 
 MPart part = ps.getActivePart();
 if (part == null)
  return null;
  
 MPartStack stack = getStackFor(part);
 if (stack == null)
  return null;
  
 MStackElement stackSelElement = stack.getSelectedElement();
 if (stackSelElement instanceof MCompositePart) {
  unsplitPart((MCompositePart) stackSelElement);
 } else {
  horizontal = !horizontal ;
  splitPart(stackSelElement);
 }
  
 return null;
}

This gives me a command that will act as a toggle and change orientation each time you split (to ensure that we can do both) and accomplishes two objectives:
  1. Allows me to fully implement the desired functionality, creating methods to be used in the implementation of the handler.
  2. Test / refine the rendering capabilities to allow for the new requirements. In this case very few were needed as you can see by looking at the commits on Bug 378298. A lot of this code is only necessary because we have to support the new MCompositePart itself and it not directly related to the split editor feature.
BTW, not to worry about the implementations of the 'split' and 'unsplit' methods used in the code above; we'll be getting back to that code later :-).

Now we have a working implementation of the feature but it's not correctly packaged since all the code is in the handler class (and that handler uses the IDE's mechanisms but we will want this functionality in e4 as well)...what to do ? what to do ?

Step 3: Packaging the code for re-use


OK, the first thing to consider is what this feature would look like in pure e4. The pattern used by the MinMaxAddon is also applicable to this scenario so let's go with it:
  1. Define a new SplitPartAddon that reacts to two new IPresentationEngine constants "SPLIT_HORIZONTAL" and "SPLIT_VERTICAL".
  2. Move the methods required to do this out of the original handler into the new addon, calling them appropriately based on the new tags being added / removed from an existing MPart.
Here's SplitterAddon (note that the complete functional implementation is only 150 lines of code):

/**
 * Listens for the IPresentationEngine's SPLIT_HORIZONTAL and SPLIT_VERTICAL tags being applied to
 * an MPart and takes the appropriate steps to split / unsplit the part
 * 
 * @since 1.1
 */
public class SplitterAddon {
 @Inject
 EModelService ms;

 @Inject
 EPartService ps;

 /**
  * Handles changes in tags
  * 
  * @param event
  */
 @Inject
 @Optional
 private void subscribeTopicTagsChanged(
   @UIEventTopic(UIEvents.ApplicationElement.TOPIC_TAGS) Event event) {
  Object changedObj = event.getProperty(EventTags.ELEMENT);

  if (!(changedObj instanceof MPart))
   return;

  MPart part = (MPart) changedObj;

  if (UIEvents.isADD(event)) {
   if (UIEvents.contains(event, UIEvents.EventTags.NEW_VALUE,
     IPresentationEngine.SPLIT_HORIZONTAL)) {
    splitPart(part, true);
   } else if (UIEvents.contains(event, UIEvents.EventTags.NEW_VALUE,
     IPresentationEngine.SPLIT_VERTICAL)) {
    splitPart(part, false);
   }
  } else if (UIEvents.isREMOVE(event)) {
   MCompositePart compPart = SplitterAddon.findContainingCompositePart(part);
   if (UIEvents.contains(event, UIEvents.EventTags.OLD_VALUE,
     IPresentationEngine.SPLIT_HORIZONTAL)) {
    unsplitPart(compPart);
   } else if (UIEvents.contains(event, UIEvents.EventTags.OLD_VALUE,
     IPresentationEngine.SPLIT_VERTICAL)) {
    unsplitPart(compPart);
   }
  }
 }

 /**
  * Finds the CompositePart containing the given part (if any)
  * 
  * @param part
  * @return The MCompositePart or 'null' if none is found
  */
 public static MCompositePart findContainingCompositePart(MPart part) {
  if (part == null)
   return null;

  MUIElement curParent = part.getParent();
  while (curParent != null && !(curParent instanceof MCompositePart))
   curParent = curParent.getParent();

  return (MCompositePart) curParent;
 }

 private void unsplitPart(MCompositePart compositePart) {
  if (compositePart == null)
   return;

  List innerElements = ms.findElements(compositePart, null, MPart.class, null);
  if (innerElements.size() < 2)
   return;

  MPart originalEditor = innerElements.get(1); // '0' is the composite part

  MElementContainer compParent = compositePart.getParent();
  int index = compParent.getChildren().indexOf(compositePart);
  compParent.getChildren().remove(compositePart);
  originalEditor.getParent().getChildren().remove(originalEditor);
  compParent.getChildren().add(index, originalEditor);

  if (ps.getActivePart() == originalEditor)
   ps.activate(null);
  ps.activate(originalEditor);
 }

 private MCompositePart createCompositePart(MPart originalPart) {
  MCompositePart compPart = ms.createModelElement(MCompositePart.class);
  compPart.setElementId("Split Host(" + originalPart.getLabel() + ")"); //$NON-NLS-1$ //$NON-NLS-2$
  compPart.setLabel(originalPart.getLabel());
  compPart.setTooltip(originalPart.getTooltip());
  compPart.setIconURI(originalPart.getIconURI());
  compPart.setCloseable(true);
  compPart.setContributionURI("bundleclass://org.eclipse.e4.ui.workbench.addons.swt/org.eclipse.e4.ui.workbench.addons.splitteraddon.SplitHost"); //$NON-NLS-1$

  // Always remove the composite part from the model
  compPart.getTags().add(EPartService.REMOVE_ON_HIDE_TAG);

  return compPart;
 }

 void splitPart(MPart partToSplit, boolean horizontal) {
  MElementContainer parent = partToSplit.getParent();
  int index = parent.getChildren().indexOf(partToSplit);

  MPart editorClone = (MPart) ms.cloneElement(partToSplit, null);

  MCompositePart compPart = createCompositePart(partToSplit);

  // Add the new composite part to the model
  compPart.getChildren().add(editorClone);
  compPart.setSelectedElement(editorClone);
  parent.getChildren().add(index, compPart);
  parent.setSelectedElement(compPart);

  // Now, add the original part into the composite
  int orientation = horizontal ? EModelService.ABOVE : EModelService.LEFT_OF;
  ms.insert(partToSplit, editorClone, orientation, 0.5f);

  ps.activate(partToSplit);
 }
}

Now we can take our original IDE command handler and replace the code that used to call the methods directly with code that adds or removes the appropriate tags on the MPart for the active editor. Here's the final code for the IDE handler:

public class SplitHandler extends AbstractHandler {
 private EModelService modelService;
 private IWorkbenchWindow window;

 /**
  * The constructor.
  */
 public SplitHandler() {
 }

 public Object execute(ExecutionEvent event) throws ExecutionException {
  // Only works for the active editor
  IEditorPart activeEditor = HandlerUtil.getActiveEditor(event);
  if (activeEditor == null)
   return null;
  
  MPart editorPart = (MPart) activeEditor.getSite().getService(MPart.class);
  if (editorPart == null)
   return null;
  
  window = HandlerUtil.getActiveWorkbenchWindowChecked(event);

  // Get services
  modelService =  editorPart.getContext().get(EModelService.class);
  
  MPartStack stack = getStackFor(editorPart);
  if (stack == null)
   return null;

  window.getShell().setRedraw(false);
  try {
   MStackElement stackSelElement = stack.getSelectedElement();
   if (stackSelElement instanceof MCompositePart) {
    List innerElements = modelService.findElements(stackSelElement, null, MPart.class, null);
    MPart originalEditor = innerElements.get(1); // '0' is the composite part
    
    originalEditor.getTags().remove(IPresentationEngine.SPLIT_HORIZONTAL);
    originalEditor.getTags().remove(IPresentationEngine.SPLIT_VERTICAL);
   } else {
    if ("false".equals(event.getParameter("Splitter.isHorizontal"))) { //$NON-NLS-1$ //$NON-NLS-2$
     editorPart.getTags().add(IPresentationEngine.SPLIT_VERTICAL);
    } else {
     editorPart.getTags().add(IPresentationEngine.SPLIT_HORIZONTAL);
    }
   }
  } finally {
   window.getShell().setRedraw(true);
  }
  
  return null;
 }
 
 private MPartStack getStackFor(MPart part) {
  MUIElement presentationElement = part.getCurSharedRef() == null ? part : part.getCurSharedRef();
  MUIElement parent = presentationElement.getParent();
  while (parent != null && !(parent instanceof MPartStack))
   parent = parent.getParent();
  
  return (MPartStack) parent;
 }
}

We also add a couple of key bindings for easy access...in this case Ctrl + '_' gives a horizontal split (one above the other) and Ctrl + '{' gives a side-by-side split.

Step 4: Release and Polish

 

Finally, we blog about it so that folks will know that it's available...;-).

Then we take the feedback and polish the feature (adding CSS to define the splitter's color...). The earlier we can release a new feature the more time we'll have to get the feedback and act on it before the actual release. If there's one thing as certain as death and taxes is that you won't  get it right the first time. I've learned that it's more productive to 'get it working, get it out',  then polish it rather than putting a ton of work into the polish up front just to trash it later based on feedback.

P.S. While the current implementation is specific to the Eclipse IDE and editors in particular we do expect to extend this feature to allow the proper splitting of any MPart. This will, however, require some more design work on allowing the MCompositePart to somehow 'host' the Toolbar and Menus from the currently active 'child' MPart so they appear in the stack and work correctly.

Alrighty then, on to the next one...different presentations based on how many monitors you have.


2 comments: