Tuesday, October 27, 2009

Key Bindings in Eclipse Editors

For tool writers, the Eclipse platform provides great facilities for binding keys to commands so that specific actions can be invoked with the right key combination. In most cases hooking into these facilities is a simple matter of activating the right context via IContextService and using a few extension points in your plugin.xml. For more complex editors, such as those with multiple Text or StyledText controls, it's not so easy. This article examines the mechanisms that the Eclipse platform uses to determine how commands are handled, and outlines an approach for implementing editors that wish to alter command handling depending on the focus control.

Suppose we are building an editor that has more than one StyledText control and uses IOperationHistory to support undo/redo. We want CTRL+Z and CTRL+Y (Command-Z and Command-Y on a Mac) to cause undo/redo in the editor. In this editor what we want is for these keys to manipulate the IOperationHistory, however when a StyledText has focus in our editor we want to invoke undo/redo on the StyledText. In other words, the user will expect undo/redo to behave differently depending on what they're doing. This is consistent with how undo/redo work throughout the rest of the platform.

Eclipse uses the following abstractions to represent the concept of keys invoking commands:

  • KeyBinding - binds a sequence of keys to a logical command
  • Command - an abstract representation for some semantic behaviour
  • IHandler - an implementation of the behaviour for a specific command

Let's take (for example) CTRL+Z. When key presses are made, they are matched with a KeyBinding. Eclipse knows which Command to invoke based on the KeyBinding (in this case the 'undo' command). Eclipse then determines which IHandler to use by looking up the handler using the logical command in IHandlerService. (There are many layers of indirection in the actual implementation -- this example is simplified for ease of understanding).

In our editor we want the operation history to be associated with undo/redo. We hook it up as follows:


OperationHistoryActionHandler undoAction= new UndoActionHandler(site, moduleEditor.getOperationContext());
PlatformUI.getWorkbench().getHelpSystem().setHelp(undoAction, IAbstractTextEditorHelpContextIds.UNDO_ACTION);
undoAction.setActionDefinitionId(IWorkbenchActionDefinitionIds.UNDO);
undoAction.setId(ITextEditorActionConstants.UNDO);

IHandlerService handlerService = (IHandlerService) site.getService(IHandlerService.class);
handlerService.activateHandler(undoAction.getActionDefinitionId(), new ActionHandler(undoAction));

When we test our editor this works as expected: we can undo changes in the editor by pressing CTRL+Z. After focusing on a StyledText in our editor however, we expect CTRL+Z to undo text that we type. In our editor these text changes aren't in our operation history until after the text control loses focus. So how do we set that up? Here's how:


TextViewer textViewer = // create the text viewer
TextViewerSupport support = new TextViewerSupport(textViewer);

Most of the work is done by this helper class:



protected class TextViewerSupport implements FocusListener, DisposeListener {

private final TextViewer textViewer;
private List handlerActivations = new ArrayList();


public TextViewerSupport(TextViewer textViewer) {
this.textViewer = textViewer;
StyledText textWidget = textViewer.getTextWidget();
textWidget.addFocusListener(this);
textWidget.addDisposeListener(this);

if (textViewer.getTextWidget().isFocusControl()) {
activateContext();
}
}
public void focusLost(FocusEvent e) {
deactivateContext();
}
public void focusGained(FocusEvent e) {
activateContext();
}
public void widgetDisposed(DisposeEvent e) {
deactivateContext();
}

protected void activateContext() {
if (handlerActivations.isEmpty()) {
activateHandler(ISourceViewer.QUICK_ASSIST,ITextEditorActionDefinitionIds.QUICK_ASSIST);
activateHandler(ISourceViewer.CONTENTASSIST_PROPOSALS,ITextEditorActionDefinitionIds.CONTENT_ASSIST_PROPOSALS);
activateHandler(ITextOperationTarget.CUT, ITextEditorActionDefinitionIds.CUT);
activateHandler(ITextOperationTarget.COPY, ITextEditorActionDefinitionIds.COPY);
activateHandler(ITextOperationTarget.PASTE, ITextEditorActionDefinitionIds.PASTE);
activateHandler(ITextOperationTarget.DELETE, ITextEditorActionDefinitionIds.DELETE);
activateHandler(ITextOperationTarget.UNDO, ITextEditorActionDefinitionIds.UNDO);
activateHandler(ITextOperationTarget.REDO, ITextEditorActionDefinitionIds.REDO);
}
}


protected void activateHandler(int operation, String actionDefinitionId) {
StyledText textWidget = textViewer.getTextWidget();
IHandler actionHandler = createActionHandler(operation, actionDefinitionId);
IHandlerActivation handlerActivation = handlerService.activateHandler(actionDefinitionId, actionHandler,new ActiveFocusControlExpression(textWidget));

handlerActivations.add(handlerActivation);
}

private IHandler createActionHandler(final int operation, String actionDefinitionId) {
Action action = new Action() {
@Override
public void run() {
if (textViewer.canDoOperation(operation)) {
textViewer.doOperation(operation);
}
}
};
action.setActionDefinitionId(actionDefinitionId);
return new ActionHandler(action);
}

protected void deactivateContext() {
if (!handlerActivations.isEmpty()) {
for (IHandlerActivation activation: handlerActivations) {
handlerService.deactivateHandler(activation);
activation.getHandler().dispose();
}
handlerActivations.clear();
}
}
}

Though TextViewerSupport looks complicated, it's really just activating different command handlers whenever the textViewer gains focus.

When the text control has focus the editor now has two handlers for the undo command. So how does Eclipse know which one to use? Digging into the internals of Eclipse reveals that it uses the handler with highest priority. But we don't set a priority no these handlers! Eclipse calculates the priority of handlers automatically based on the enablement expression of the handler. The priority of the enablement expression is based in part on the variables that the expression references. This leads us to the last part of our solution, ActiveFocusControlExpression:


/**
* An expression that evaluates to true if and only if the current focus control is the one provided.
* Has a very high priority in order to ensure proper conflict resolution.
*/
public class ActiveFocusControlExpression extends Expression {

private Control focusControl;

public ActiveFocusControlExpression(Control control) {
focusControl = control;
}

@Override
public void collectExpressionInfo(ExpressionInfo info) {
info.markDefaultVariableAccessed(); // give it a very high priority
info.addVariableNameAccess(ISources.ACTIVE_SHELL_NAME);
info.addVariableNameAccess(ISources.ACTIVE_WORKBENCH_WINDOW_NAME);
}

@Override
public EvaluationResult evaluate(IEvaluationContext context)
throws CoreException {
if (Display.getCurrent() != null && focusControl.isFocusControl()) {
return EvaluationResult.TRUE;
}
return EvaluationResult.FALSE;
}
}

In collectExpressionInfo we ensure that the expression indicates that it uses the default variable. This gives the expression a very high priority. Since the expression is only enabled when our control has focus, we've ensured that undo is directed to our text control at the right time.

Credit: The approach described in this article is inspired by a class CommonTextSupport (authored by Steffen Pingel) from the Mylyn project.

No comments: