blob: aee3a786b6a598c31403e6d504b78b9ed438c186 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2000, 2003 IBM Corporation and others. All rights reserved.
* This program and the accompanying materials are made available under the
* terms of the Common Public License v1.0 which accompanies this distribution,
* and is available at http://www.eclipse.org/legal/cpl-v10.html
*
* Contributors: IBM Corporation - initial API and implementation
******************************************************************************/
package org.eclipse.ui.internal.keys;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.TreeMap;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.StyledText;
import org.eclipse.swt.events.SelectionEvent;
import org.eclipse.swt.events.SelectionListener;
import org.eclipse.swt.graphics.Color;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.layout.GridLayout;
import org.eclipse.swt.widgets.Control;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Event;
import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Listener;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.Table;
import org.eclipse.swt.widgets.TableColumn;
import org.eclipse.swt.widgets.TableItem;
import org.eclipse.swt.widgets.Widget;
import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.ui.IWindowListener;
import org.eclipse.ui.IWorkbench;
import org.eclipse.ui.IWorkbenchWindow;
import org.eclipse.ui.commands.ICommand;
import org.eclipse.ui.commands.ICommandManager;
import org.eclipse.ui.commands.NotDefinedException;
import org.eclipse.ui.keys.KeySequence;
import org.eclipse.ui.keys.KeyStroke;
import org.eclipse.ui.keys.KeySupport;
import org.eclipse.ui.keys.ParseException;
import org.eclipse.ui.internal.IPreferenceConstants;
import org.eclipse.ui.internal.Workbench;
import org.eclipse.ui.internal.WorkbenchMessages;
import org.eclipse.ui.internal.WorkbenchPlugin;
import org.eclipse.ui.internal.commands.CommandManager;
import org.eclipse.ui.internal.util.StatusLineContributionItem;
import org.eclipse.ui.internal.util.Util;
/**
* <p>
* Controls the keyboard input into the workbench key binding architecture.
* This allows key events to be programmatically pushed into the key binding
* architecture -- potentially triggering the execution of commands. It is used
* by the <code>Workbench</code> to listen for events on the <code>Display</code>.
* </p>
* <p>
* This class is not designed to be thread-safe. It is assumed that all access
* to the <code>press</code> method is done through the event loop. Accessing
* this method outside the event loop can cause corruption of internal state.
* </p>
*
* @since 3.0
*/
public class WorkbenchKeyboard {
static {
initializeOutOfOrderKeys();
}
/**
* The properties key for the key strokes that should be processed out of
* order.
*/
static final String OUT_OF_ORDER_KEYS = "OutOfOrderKeys"; //$NON-NLS-1$
/** The collection of keys that are to be processed out-of-order. */
static KeySequence outOfOrderKeys;
/**
* The translation bundle in which to look up internationalized text.
*/
private final static ResourceBundle RESOURCE_BUNDLE =
ResourceBundle.getBundle(WorkbenchKeyboard.class.getName());
/**
* Generates any key strokes that are near matches to the given event. The
* first such key stroke is always the exactly matching key stroke.
*
* @param event
* The event from which the key strokes should be generated;
* must not be <code>null</code>.
* @return The set of nearly matching key strokes. It is never <code>null</code>,
* but may be empty.
*/
public static List generatePossibleKeyStrokes(Event event) {
List keyStrokes = new ArrayList();
/*
* If this is not a keyboard event, then there are no key strokes. This
* can happen if we are listening to focus traversal events.
*/
if ((event.stateMask == 0) && (event.keyCode == 0) && (event.character == 0)) {
return keyStrokes;
}
// Add each unique key stroke to the list for consideration.
KeyStroke keyStroke;
keyStrokes.add(
KeySupport.convertAcceleratorToKeyStroke(
KeySupport.convertEventToUnmodifiedAccelerator(event)));
keyStroke =
KeySupport.convertAcceleratorToKeyStroke(
KeySupport.convertEventToUnshiftedModifiedAccelerator(event));
if (!keyStrokes.contains(keyStroke)) {
keyStrokes.add(keyStroke);
}
keyStroke =
KeySupport.convertAcceleratorToKeyStroke(
KeySupport.convertEventToModifiedAccelerator(event));
if (!keyStrokes.contains(keyStroke)) {
keyStrokes.add(keyStroke);
}
return keyStrokes;
}
/**
* Initializes the <code>outOfOrderKeys</code> member variable using the
* keys defined in the properties file.
*/
private static void initializeOutOfOrderKeys() {
// Get the key strokes which should be out of order.
String keysText = WorkbenchMessages.getString(OUT_OF_ORDER_KEYS);
outOfOrderKeys = KeySequence.getInstance();
try {
outOfOrderKeys = KeySequence.getInstance(keysText);
} catch (ParseException e) {
String message = "Could not parse out-of-order keys definition: '" + keysText + "'. Continuing with no out-of-order keys."; //$NON-NLS-1$ //$NON-NLS-2$
WorkbenchPlugin.log(
message,
new Status(IStatus.ERROR, WorkbenchPlugin.PI_WORKBENCH, 0, message, e));
}
}
/**
* <p>
* Determines whether the given event represents a key press that should be
* handled as an out-of-order event. An out-of-order key press is one that
* is passed to the focus control first. Only if the focus control fails to
* respond will the regular key bindings get applied.
* </p>
* <p>
* Care must be taken in choosing which keys are chosen as out-of-order
* keys. This method has only been designed and test to work with the
* unmodified "Escape" key stroke.
* </p>
*
* @param keyStrokes
* The key stroke in which to look for out-of-order keys; must
* not be <code>null</code>.
* @return <code>true</code> if the key is an out-of-order key; <code>false</code>
* otherwise.
*/
private static boolean isOutOfOrderKey(List keyStrokes) {
// Compare to see if one of the possible key strokes is out of order.
Iterator keyStrokeItr = keyStrokes.iterator();
while (keyStrokeItr.hasNext()) {
if (outOfOrderKeys.getKeyStrokes().contains(keyStrokeItr.next())) {
return true;
}
}
return false;
}
/**
* The command manager to be used to resolve key bindings. This member
* variable should never be <code>null</code>.
*/
private final ICommandManager commandManager;
/**
* The listener that runs key events past the global key bindings.
*/
final Listener keyDownFilter = new Listener() {
public void handleEvent(Event event) {
filterKeySequenceBindings(event);
}
};
/**
* The listener that checks to see whether all of the modifier keys have
* been released.
*/
final Listener keyUpFilter = new Listener() {
public void handleEvent(Event event) {
checkModifierKeys(event);
}
};
/**
* The <code>Shell</code> displayed to the user to assist them in
* completing a multi-stroke keyboard shortcut.
*/
private Shell multiKeyAssistShell = null;
/**
* The listener that allows out-of-order key processing to hook back into
* the global key bindings.
*/
final OutOfOrderListener outOfOrderListener = new OutOfOrderListener(this);
/**
* The listener that allows out-of-order key processing on <code>StyledText</code>
* widgets to detect useful work in a verify key listener.
*/
final OutOfOrderVerifyListener outOfOrderVerifyListener =
new OutOfOrderVerifyListener(outOfOrderListener);
/**
* The time at which the last timer was started. This is used to judge if a
* sufficient amount of time has elapsed. This is simply the output of
* <code>System.currentTimeMillis()</code>.
*/
private long startTime = Long.MAX_VALUE;
/**
* The mode is the current state of the key binding architecture. In the
* case of multi-stroke key bindings, this can be a partially complete key
* binding.
*/
private final KeyBindingState state;
/**
* The window listener responsible for maintaining internal state as the
* focus moves between windows on the desktop.
*/
private final IWindowListener windowListener = new IWindowListener() {
/*
* (non-Javadoc)
*
* @see org.eclipse.ui.IWindowListener#windowActivated(org.eclipse.ui.IWorkbenchWindow)
*/
public void windowActivated(IWorkbenchWindow window) {
checkActiveWindow(window);
}
/*
* (non-Javadoc)
*
* @see org.eclipse.ui.IWindowListener#windowClosed(org.eclipse.ui.IWorkbenchWindow)
*/
public void windowClosed(IWorkbenchWindow window) {
// Do nothing.
}
/*
* (non-Javadoc)
*
* @see org.eclipse.ui.IWindowListener#windowDeactivated(org.eclipse.ui.IWorkbenchWindow)
*/
public void windowDeactivated(IWorkbenchWindow window) {
// Do nothing
}
/*
* (non-Javadoc)
*
* @see org.eclipse.ui.IWindowListener#windowOpened(org.eclipse.ui.IWorkbenchWindow)
*/
public void windowOpened(IWorkbenchWindow window) {
// Do nothing.
}
};
/**
* The workbench on which this keyboard interface should act.
*/
private final IWorkbench workbench;
/**
* Constructs a new instance of <code>WorkbenchKeyboard</code> associated
* with a particular workbench.
*
* @param associatedWorkbench
* The workbench with which this keyboard interface should work;
* must not be <code>null</code>.
*/
public WorkbenchKeyboard(Workbench associatedWorkbench) {
workbench = associatedWorkbench;
state = new KeyBindingState(associatedWorkbench);
commandManager = workbench.getCommandManager();
workbench.addWindowListener(windowListener);
}
/**
* Verifies that the active workbench window is the same as the workbench
* window associated with the state. This is used to verify that the state
* is properly reset as focus changes. When they are not the same, the
* state is reset and associated with the newly activated window.
*
* @param window
* The activated window; must not be <code>null</code>.
*/
private void checkActiveWindow(IWorkbenchWindow window) {
if (!window.equals(state.getAssociatedWindow())) {
state.setCollapseFully(true);
resetState();
state.setAssociatedWindow(window);
}
}
/**
* Checks to see if the modifier keys are all released now. If they are all
* released, then the state will be allowed to collapse fully, and the
* state will reset itself.
*
* @param event
* The event to check for modifier keys; must not be <code>null</code>.
*/
private void checkModifierKeys(Event event) {
if ((event.type == SWT.KeyUp) && (event.stateMask == event.keyCode)) {
state.setCollapseFully(true);
if (state.isSafeToReset()) {
resetState();
}
}
}
/**
* Performs the actual execution of the command by looking up the current
* handler from the command manager. If there is a handler and it is
* enabled, then it tries the actual execution. Execution failures are
* logged. When this method completes, the key binding state is reset.
*
* @param commandId
* The identifier for the command that should be executed;
* should not be <code>null</code>.
* @param event
* The event triggering the execution. This is needed for
* backwards compatability and might be removed in the future.
* This value should not be <code>null</code>.
* @return <code>true</code> if there was a handler; <code>false</code>
* otherwise.
*/
private boolean executeCommand(String commandId, Event event) {
// Reset the key binding state (close window, clear status line, etc.)
resetState();
// Dispatch to the handler.
Map actionsById = ((CommandManager) workbench.getCommandManager()).getActionsById();
org.eclipse.ui.commands.IAction action =
(org.eclipse.ui.commands.IAction) actionsById.get(commandId);
if (action != null && action.isEnabled()) {
try {
action.execute(event);
} catch (Exception e) {
String message = "Action for command '" + commandId + "' failed to execute properly."; //$NON-NLS-1$ //$NON-NLS-2$
WorkbenchPlugin.log(
message,
new Status(IStatus.ERROR, WorkbenchPlugin.PI_WORKBENCH, 0, message, e));
}
}
return (action != null);
}
/**
* <p>
* Launches the command matching a the typed key. This filter an incoming
* <code>SWT.KeyDown</code> or <code>SWT.Traverse</code> event at the
* level of the display (i.e., before it reaches the widgets). It does not
* allow processing in a dialog or if the key strokes does not contain a
* natural key.
* </p>
* <p>
* Some key strokes (defined as a property) are declared as out-of-order
* keys. This means that they are processed by the widget <em>first</em>.
* Only if the other widget listeners do no useful work does it try to
* process key bindings. For example, "ESC" can cancel the current widget
* action, if there is one, without triggering key bindings.
* </p>
*
* @param event
* The incoming event; must not be <code>null</code>.
*/
private void filterKeySequenceBindings(Event event) {
/*
* Only process key strokes containing natural keys to trigger key
* bindings.
*/
if ((event.keyCode & SWT.MODIFIER_MASK) != 0)
return;
// Don't allow dialogs to process key bindings.
if (event.widget instanceof Control) {
Shell shell = ((Control) event.widget).getShell();
if (shell.getParent() != null)
return;
}
// Allow special key out-of-order processing.
List keyStrokes = generatePossibleKeyStrokes(event);
if (isOutOfOrderKey(keyStrokes)) {
if (event.type == SWT.KeyDown) {
Widget widget = event.widget;
if (widget instanceof StyledText) {
/*
* KLUDGE. Some people try to do useful work in verify
* listeners. The way verify listeners work in SWT, we need
* to verify the key as well; otherwise, we can detect that
* useful work has been done.
*/
((StyledText) widget).addVerifyKeyListener(outOfOrderVerifyListener);
} else {
widget.addListener(SWT.KeyDown, outOfOrderListener);
}
}
/*
* Otherwise, we count on a key down arriving eventually. Expecting
* out of order handling on Ctrl+Tab, for example, is a bad idea
* (stick to keys that are not window traversal keys).
*/
} else {
processKeyEvent(keyStrokes, event);
}
}
/**
* An accessor for the filter that processes key down and traverse events
* on the display.
*
* @return The global key down and traverse filter; never <code>null</code>.
*/
public Listener getKeyDownFilter() {
return keyDownFilter;
}
/**
* An accessor for the filter that processes key up events on the display.
*
* @return The global key up filter; never <code>null</code>.
*/
public Listener getKeyUpFilter() {
return keyUpFilter;
}
/**
* Determines whether the key sequence is a perfect match for any command.
* If there is a match, then the corresponding command identifier is
* returned.
*
* @param keySequence
* The key sequence to check for a match; must never be <code>null</code>.
* @return The command identifier for the perfectly matching command;
* <code>null</code> if no command matches.
*/
private String getPerfectMatch(KeySequence keySequence) {
return commandManager.getPerfectMatch(keySequence);
}
/**
* Changes the key binding state to the given value. This should be an
* incremental change, but there are no checks to guarantee this is so. It
* also sets up a <code>Shell</code> to be displayed after one second has
* elapsed. This shell will show the user the possible completions for what
* they have typed.
*
* @param sequence
* The new key sequence for the state; should not be <code>null</code>.
*/
private void incrementState(KeySequence sequence) {
// Record the starting time.
startTime = System.currentTimeMillis();
// Update the state.
state.setCurrentSequence(sequence);
state.setAssociatedWindow(workbench.getActiveWorkbenchWindow());
// After 1s, open a shell displaying the possible completions.
final IPreferenceStore store = WorkbenchPlugin.getDefault().getPreferenceStore();
if (store.getBoolean(IPreferenceConstants.MULTI_KEY_ASSIST)) {
final Display display = workbench.getDisplay();
display
.timerExec(
1000 * store.getInt(IPreferenceConstants.MULTI_KEY_ASSIST_TIME),
new Runnable() {
public void run() {
if (System.currentTimeMillis() > (startTime - 1000L)) {
openMultiKeyAssistShell(display);
}
}
});
}
}
/**
* Determines whether the key sequence partially matches on of the active
* key bindings.
*
* @param keySequence
* The key sequence to check for a partial match; must never be
* <code>null</code>.
* @return <code>true</code> if there is a partial match; <code>false</code>
* otherwise.
*/
private boolean isPartialMatch(KeySequence keySequence) {
return commandManager.isPartialMatch(keySequence);
}
/**
* Determines whether the key sequence perfectly matches on of the active
* key bindings.
*
* @param keySequence
* The key sequence to check for a perfect match; must never be
* <code>null</code>.
* @return <code>true</code> if there is a perfect match; <code>false</code>
* otherwise.
*/
private boolean isPerfectMatch(KeySequence keySequence) {
return commandManager.isPerfectMatch(keySequence);
}
/**
* Opens a <code>Shell</code> to assist the user in completing a
* multi-stroke key binding. After this method completes, <code>multiKeyAssistShell</code>
* should point at the newly opened window.
*
* @param display
* The display on which the shell should be opened; must not be
* <code>null</code>.
*/
private void openMultiKeyAssistShell(final Display display) {
// Get the status line. If none, then abort.
StatusLineContributionItem statusLine = state.getStatusLine();
if (statusLine == null) {
return;
}
Point statusLineLocation = statusLine.getDisplayLocation();
if (statusLineLocation == null) {
return;
}
// Set up the shell.
multiKeyAssistShell = new Shell(display, SWT.NO_TRIM);
GridLayout layout = new GridLayout();
layout.marginHeight = 0;
layout.marginWidth = 0;
multiKeyAssistShell.setLayout(layout);
multiKeyAssistShell.setBackground(display.getSystemColor(SWT.COLOR_INFO_BACKGROUND));
// Get the list of items.
Map partialMatches = new TreeMap(new Comparator() {
public int compare(Object a, Object b) {
KeySequence sequenceA = (KeySequence) a;
KeySequence sequenceB = (KeySequence) b;
return sequenceA.format().compareTo(sequenceB.format());
}
});
partialMatches.putAll(commandManager.getPartialMatches(state.getCurrentSequence()));
Iterator partialMatchItr = partialMatches.entrySet().iterator();
while (partialMatchItr.hasNext()) {
Map.Entry entry = (Map.Entry) partialMatchItr.next();
String commandId = (String) entry.getValue();
ICommand command = commandManager.getCommand(commandId);
// TODO The enabled property of ICommand is broken.
if (!command.isDefined() || !command.isActive() // ||
// !command.isEnabled()
) {
partialMatchItr.remove();
}
}
// Layout the partial matches.
if (partialMatches.isEmpty()) {
Label noMatchesLabel = new Label(multiKeyAssistShell, SWT.NULL);
noMatchesLabel.setText(Util.translateString(RESOURCE_BUNDLE, "NoMatches.Message")); //$NON-NLS-1$
noMatchesLabel.setLayoutData(new GridData(GridData.FILL_BOTH));
noMatchesLabel.setBackground(multiKeyAssistShell.getBackground());
} else {
final Table completionsTable = new Table(multiKeyAssistShell, SWT.SINGLE);
completionsTable.setBackground(multiKeyAssistShell.getBackground());
// Initialize the rows.
final List commands = new ArrayList(); // remember commands
completionsTable.setLayoutData(new GridData(GridData.FILL_BOTH));
new TableColumn(completionsTable, SWT.LEFT);
new TableColumn(completionsTable, SWT.LEFT);
Iterator itemsItr = partialMatches.entrySet().iterator();
while (itemsItr.hasNext()) {
Map.Entry entry = (Map.Entry) itemsItr.next();
KeySequence sequence = (KeySequence) entry.getKey();
String commandId = (String) entry.getValue();
ICommand command = commandManager.getCommand(commandId);
try {
String[] text = { sequence.format(), command.getName()};
TableItem item = new TableItem(completionsTable, SWT.NULL);
item.setText(text);
commands.add(command);
} catch (NotDefinedException e) {
// Not much to do, but this shouldn't really happen.
}
}
// If you double-click on the table, it should execute the selected
// command.
completionsTable.addSelectionListener(new SelectionListener() {
public void widgetDefaultSelected(SelectionEvent e) {
int selectionIndex = completionsTable.getSelectionIndex();
if (selectionIndex >= 0) {
ICommand command = (ICommand) commands.get(selectionIndex);
executeCommand(command.getId(), new Event());
}
}
public void widgetSelected(SelectionEvent e) {
// Do nothing
}
});
}
// Size the shell.
multiKeyAssistShell.pack();
Point assistShellSize = multiKeyAssistShell.getSize();
if (assistShellSize.x > 300) {
assistShellSize.x = 300;
}
if (assistShellSize.y > 200) {
assistShellSize.y = 200;
}
multiKeyAssistShell.setSize(assistShellSize);
// Position the shell.
Point assistShellLocation =
new Point(statusLineLocation.x, statusLineLocation.y - assistShellSize.y);
Rectangle displayBounds = display.getBounds();
final int displayRightEdge = displayBounds.x + displayBounds.width;
if (assistShellLocation.x < displayBounds.x) {
assistShellLocation.x = displayBounds.x;
} else if ((assistShellLocation.x + assistShellSize.x) > displayRightEdge) {
assistShellLocation.x = displayRightEdge - assistShellSize.x;
}
final int displayBottomEdge = displayBounds.y + displayBounds.height;
if (assistShellLocation.y < displayBounds.y) {
assistShellLocation.y = displayBounds.y;
} else if ((assistShellLocation.y + assistShellSize.y) > displayBottomEdge) {
assistShellLocation.y = displayBottomEdge - assistShellSize.y;
}
multiKeyAssistShell.setLocation(assistShellLocation);
// If the shell loses focus, it should be closed.
multiKeyAssistShell.addListener(SWT.Deactivate, new Listener() {
public void handleEvent(Event event) {
multiKeyAssistShell.close();
multiKeyAssistShell = null;
}
});
// Open the shell.
multiKeyAssistShell.open();
}
/**
* Processes a key press with respect to the key binding architecture. This
* updates the mode of the command manager, and runs the current handler
* for the command that matches the key sequence, if any.
*
* @param potentialKeyStrokes
* The key strokes that could potentially match, in the order of
* priority; must not be <code>null</code>.
* @param event
* The event to pass to the action; may be <code>null</code>.
* @return <code>true</code> if a command is executed; <code>false</code>
* otherwise.
*/
public boolean press(List potentialKeyStrokes, Event event) {
// TODO remove event parameter once key-modified actions are removed
KeySequence sequenceBeforeKeyStroke = state.getCurrentSequence();
for (Iterator iterator = potentialKeyStrokes.iterator(); iterator.hasNext();) {
KeySequence sequenceAfterKeyStroke =
KeySequence.getInstance(sequenceBeforeKeyStroke, (KeyStroke) iterator.next());
if (isPartialMatch(sequenceAfterKeyStroke)) {
final IPreferenceStore store = WorkbenchPlugin.getDefault().getPreferenceStore();
state.setCollapseFully(!store.getBoolean(IPreferenceConstants.MULTI_KEY_ROCKER));
incrementState(sequenceAfterKeyStroke);
return true;
} else if (isPerfectMatch(sequenceAfterKeyStroke)) {
String commandId = getPerfectMatch(sequenceAfterKeyStroke);
return (executeCommand(commandId, event) || sequenceBeforeKeyStroke.isEmpty());
}
}
resetState();
return false;
}
/**
* <p>
* Actually performs the processing of the key event by interacting with
* the <code>ICommandManager</code>. If work is carried out, then the
* event is stopped here (i.e., <code>event.doit = false</code>). It
* does not do any processing if there are no matching key strokes.
* </p>
* <p>
* If the active <code>Shell</code> is not the same as the one to which
* the state is associated, then a reset occurs.
* </p>
*
* @param keyStrokes
* The set of all possible matching key strokes; must not be
* <code>null</code>.
* @param event
* The event to process; must not be <code>null</code>.
*/
void processKeyEvent(List keyStrokes, Event event) {
// Dispatch the keyboard shortcut, if any.
if ((!keyStrokes.isEmpty()) && (press(keyStrokes, event))) {
switch (event.type) {
case SWT.KeyDown :
event.doit = false;
break;
case SWT.Traverse :
event.detail = SWT.TRAVERSE_NONE;
event.doit = true;
break;
default :
}
event.type = SWT.NONE;
}
}
/**
* Resets the state, and cancels any running timers. If there is a <code>Shell</code>
* currently open, then it closes it.
*/
private void resetState() {
startTime = Long.MAX_VALUE;
state.reset();
if ((multiKeyAssistShell != null) && (!multiKeyAssistShell.isDisposed())) {
multiKeyAssistShell.close();
multiKeyAssistShell.dispose();
multiKeyAssistShell = null;
}
}
}