blob: 0e4ed4e5633b01a39bdba2bdbe056aae4b04e0d5 [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.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.DisposeEvent;
import org.eclipse.swt.events.DisposeListener;
import org.eclipse.swt.events.FocusEvent;
import org.eclipse.swt.events.FocusListener;
import org.eclipse.swt.events.ModifyEvent;
import org.eclipse.swt.events.ModifyListener;
import org.eclipse.swt.graphics.Font;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Event;
import org.eclipse.swt.widgets.Listener;
import org.eclipse.swt.widgets.Text;
import org.eclipse.ui.keys.KeySequence;
import org.eclipse.ui.keys.KeyStroke;
import org.eclipse.ui.keys.NaturalKey;
import org.eclipse.ui.keys.ParseException;
import org.eclipse.ui.keys.SWTKeySupport;
import org.eclipse.ui.keys.SpecialKey;
/**
* A wrapper around the SWT text widget that traps literal key presses and
* converts them into key sequences for display. There are two types of key
* strokes that are displayed: complete and incomplete. A complete key stroke
* is one with a natural key, while an incomplete one has no natural key.
* Incomplete key strokes are only displayed until they are made complete or
* their component key presses are released.
*/
public final class KeySequenceText {
/**
* A key listener that traps incoming events and displays them in the
* wrapped text field. It has no effect on traversal operations.
*/
private class KeyTrapListener implements Listener {
/**
* The index at which insertion should occur. This is used if there is
* a replacement occurring in the middle of the stroke, and the first
* key stroke was incomplete.
*/
private int insertionIndex = -1;
/**
* Resets the insertion index to point nowhere. In other words, it is
* set to <code>-1</code>.
*/
void clearInsertionIndex() {
insertionIndex = -1;
}
/**
* Deletes the current selection. If there is no selection, then it
* deletes the last key stroke.
*
* @param keyStrokes
* The key strokes from which to delete. This list must not
* be <code>null</code>, and must represent a valid key
* sequence.
*/
private void deleteKeyStroke(List keyStrokes) {
clearInsertionIndex();
if (hasSelection()) {
/*
* Delete the current selection -- disallowing incomplete
* strokes in the middle of the sequence.
*/
deleteSelection(keyStrokes, false);
} else {
// Remove the last key stroke.
if (!keyStrokes.isEmpty()) {
keyStrokes.remove(keyStrokes.size() - 1);
}
}
}
/**
* Handles the key pressed and released events on the wrapped text
* widget. This makes sure to either add the pressed key to the
* temporary key stroke, or complete the current temporary key stroke
* and prompt for the next. In the case of a key release, this makes
* sure that the temporary stroke is correctly displayed --
* corresponding with modifier keys that may have been released.
*
* @param event
* The triggering event; must not be <code>null</code>.
*/
public void handleEvent(Event event) {
List keyStrokes = new ArrayList(getKeySequence().getKeyStrokes());
// Dispatch the event to the correct handler.
if (event.type == SWT.KeyDown) {
handleKeyDown(event, keyStrokes);
} else if (event.type == SWT.KeyUp) {
handleKeyUp(event, keyStrokes);
}
// Update the underlying widget.
setKeySequence(KeySequence.getInstance(keyStrokes));
// Prevent the event from reaching the widget.
event.doit = false;
}
/**
* Handles the case where the key event is an <code>SWT.KeyDown</code>
* event. This either causes a deletion (if it is an unmodified
* backspace key stroke), or an insertion (if it is any other key).
*
* @param event
* The trigger key down event; must not be <code>null</code>.
* @param keyStrokes
* The current list of key strokes. This valud must not be
* <code>null</code>, and it must represent a valid key
* sequence.
*/
private void handleKeyDown(Event event, List keyStrokes) {
// Is it an unmodified backspace character?
if ((event.character == SWT.BS) && (event.stateMask == 0)) {
deleteKeyStroke(keyStrokes);
} else {
insertKeyStroke(event, keyStrokes);
}
}
/**
* Handles the case where the key event is an <code>SWT.KeyUp</code>
* event. This resets the insertion index. If there is an incomplete
* stroke, then that incomplete stroke is modified to match the keys
* that are still held. If no keys are held, then the incomplete stroke
* is removed.
*
* @param event
* The triggering event; must not be <code>null</code>
* @param keyStrokes
* The key strokes that are part of the current key
* sequence; these key strokes are guaranteed to represent a
* valid key sequence. This valud must not be <code>null</code>.
*/
private void handleKeyUp(Event event, List keyStrokes) {
if (hasIncompleteStroke()) {
/*
* Figure out the SWT integer representation of the remaining
* values.
*/
Event mockEvent = new Event();
if ((event.keyCode & SWT.MODIFIER_MASK) != 0) {
// This key up is a modifier key being released.
mockEvent.stateMask = event.stateMask - event.keyCode;
} else {
/*
* This key up is the other end of a key down that was
* trapped by the operating system or window manager.
*/
mockEvent.stateMask = event.stateMask;
}
/*
* Get a reasonable facsimile of the stroke that is still
* pressed.
*/
int key =
SWTKeySupport.convertEventToUnmodifiedAccelerator(mockEvent);
KeyStroke remainingStroke =
SWTKeySupport.convertAcceleratorToKeyStroke(key);
if (!keyStrokes.isEmpty()) {
keyStrokes.remove(keyStrokes.size() - 1);
}
if (!remainingStroke.getModifierKeys().isEmpty()) {
keyStrokes.add(remainingStroke);
}
}
}
/**
* <p>
* Handles the case where a key down event is leading to a key stroke
* being inserted. The current selection is deleted, and an invalid
* remanents of the stroke are also removed. The insertion is carried
* out at the cursor position.
* </p>
* <p>
* If only a natural key is selected (as part of a larger key stroke),
* then it is possible for the user to press a natural key to replace
* the old natural key. In this situation, pressing any modifier keys
* will replace the whole thing.
* </p>
* <p>
* If the insertion point is not at the end of the sequence, then
* incomplete strokes will not be immediately inserted. Only when the
* sequence is completed is the stroke inserted. This is a requirement
* as the widget must always represent a valid key sequence. The
* insertion point is tracked using <code>insertionIndex</code>,
* which is an index into the key stroke array.
* </p>
*
* @param event
* The triggering key down event; must not be <code>null</code>.
* @param keyStrokes
* The key strokes into which the current stroke should be
* inserted. This value must not be <code>null</code>,
* and must represent a valid key sequence.
*/
private void insertKeyStroke(Event event, List keyStrokes) {
// Compute the key stroke to insert.
int key = SWTKeySupport.convertEventToUnmodifiedAccelerator(event);
KeyStroke stroke = SWTKeySupport.convertAcceleratorToKeyStroke(key);
/*
* Only insert the stroke if it is *not ScrollLock. Let's not get
* silly
*/
if ((SpecialKey.NUM_LOCK.equals(stroke.getNaturalKey()))
|| (SpecialKey.CAPS_LOCK.equals(stroke.getNaturalKey()))
|| (SpecialKey.SCROLL_LOCK.equals(stroke.getNaturalKey()))) {
return;
}
if (insertionIndex != -1) {
// There is a previous replacement still going on.
if (stroke.isComplete()) {
insertStrokeAt(keyStrokes, stroke, insertionIndex);
clearInsertionIndex();
}
} else if (hasSelection()) {
// There is a selection that needs to be replaced.
insertionIndex =
deleteSelection(keyStrokes, stroke.isComplete());
if ((stroke.isComplete())
|| (insertionIndex >= keyStrokes.size())) {
insertStrokeAt(keyStrokes, stroke, insertionIndex);
clearInsertionIndex();
}
} else {
// No selection, so remove the incomplete stroke, if any
if ((hasIncompleteStroke()) && (!keyStrokes.isEmpty())) {
keyStrokes.remove(keyStrokes.size() - 1);
}
// And then add the new stroke.
if ((keyStrokes.isEmpty())
|| (insertionIndex >= keyStrokes.size())
|| (isCursorInLastPosition())) {
insertStrokeAt(keyStrokes, stroke, keyStrokes.size());
clearInsertionIndex();
} else {
/*
* I'm just getting the insertionIndex here. No actual
* deletion should occur.
*/
insertionIndex =
deleteSelection(keyStrokes, stroke.isComplete());
if (stroke.isComplete()) {
insertStrokeAt(keyStrokes, stroke, insertionIndex);
clearInsertionIndex();
}
}
}
}
}
/**
* A traversal listener that blocks all traversal except for tabs and arrow
* keys.
*/
private class TraversalFilter implements Listener {
/**
* Handles the traverse event on the text field wrapped by this class.
* It swallows all traverse events example for tab and arrow key
* navigation. The other forms of navigation can be reached by tabbing
* off of the control.
*
* @param event
* The trigger event; must not be <code>null</code>.
*/
public void handleEvent(Event event) {
switch (event.detail) {
case SWT.TRAVERSE_ESCAPE :
case SWT.TRAVERSE_MNEMONIC :
case SWT.TRAVERSE_NONE :
case SWT.TRAVERSE_PAGE_NEXT :
case SWT.TRAVERSE_PAGE_PREVIOUS :
case SWT.TRAVERSE_RETURN :
event.type = SWT.None;
event.doit = false;
break;
case SWT.TRAVERSE_TAB_NEXT :
case SWT.TRAVERSE_TAB_PREVIOUS :
// Check if modifiers other than just 'Shift' were
// down.
if ((event.stateMask & (SWT.MODIFIER_MASK ^ SWT.SHIFT))
!= 0) {
// Modifiers other than shift were down.
event.type = SWT.None;
event.doit = false;
break;
}
// fall through -- either no modifiers, or just shift.
case SWT.TRAVERSE_ARROW_NEXT :
case SWT.TRAVERSE_ARROW_PREVIOUS :
default :
// Let the traversal happen, but clear the incomplete
// stroke
if (hasIncompleteStroke()) {
List keyStrokes =
new ArrayList(getKeySequence().getKeyStrokes());
if (!keyStrokes.isEmpty()) {
keyStrokes.remove(keyStrokes.size() - 1);
}
setKeySequence(KeySequence.getInstance(keyStrokes));
}
}
}
}
/**
* The manager resposible for installing and removing the traversal filter
* when the key sequence entry widget gains and loses focus.
*/
private class TraversalFilterManager implements FocusListener {
/** The managed filter. We only need one instance. */
private TraversalFilter filter = new TraversalFilter();
/**
* Attaches the global traversal filter.
*
* @param event
* Ignored.
*/
public void focusGained(FocusEvent event) {
Display.getCurrent().addFilter(SWT.Traverse, filter);
}
/**
* Detaches the global traversal filter.
*
* @param event
* Ignored.
*/
public void focusLost(FocusEvent event) {
Display.getCurrent().removeFilter(SWT.Traverse, filter);
}
}
/**
* A modification listener that makes sure that external events to this
* class (i.e., direct modification of the underlying text) do not break
* this class' view of the world.
*/
private class UpdateSequenceListener implements ModifyListener {
/**
* Handles the modify event on the underlying text widget.
*
* @param event
* The triggering event; ignored.
*/
public void modifyText(ModifyEvent event) {
try {
// The original sequence.
KeySequence originalSequence = getKeySequence();
// The new sequence drawn from the text.
String contents = getText();
KeySequence newSequence = KeySequence.getInstance(contents);
// Check to see if they're the same.
if (!originalSequence.equals(newSequence)) {
setKeySequence(newSequence);
}
} catch (ParseException e) {
// Abort any cut/paste-driven modifications
setKeySequence(getKeySequence());
}
}
}
static {
TreeSet trappedKeys = new TreeSet();
trappedKeys.add(SWTKeySupport.convertAcceleratorToKeyStroke(SWT.TAB));
trappedKeys.add(
SWTKeySupport.convertAcceleratorToKeyStroke(SWT.TAB | SWT.SHIFT));
trappedKeys.add(SWTKeySupport.convertAcceleratorToKeyStroke(SWT.BS));
List trappedKeyList = new ArrayList(trappedKeys);
TRAPPED_KEYS = Collections.unmodifiableList(trappedKeyList);
}
/** An empty string instance for use in clearing text values. */
private static final String EMPTY_STRING = ""; //$NON-NLS-1$
/**
* The special integer value for the maximum number of strokes indicating
* that an infinite number should be allowed.
*/
public static final int INFINITE = -1;
/**
* The keys trapped by this widget. This list is guaranteed to be roughly
* accurate. Perfection is not possible, as SWT does not export traversal
* keys as constants.
*/
public static final List TRAPPED_KEYS;
/**
* The key filter attached to the underlying widget that traps key events.
*/
private final KeyTrapListener keyFilter = new KeyTrapListener();
/**
* The text of the key sequence -- containing only the complete key
* strokes.
*/
private KeySequence keySequence = KeySequence.getInstance();
/** The maximum number of key strokes permitted in the sequence. */
private int maxStrokes = INFINITE;
/** The text widget that is wrapped for this class. */
private final Text text;
/**
* The listener that makes sure that the text widget remains up-to-date
* with regards to external modification of the text (e.g., cut & pasting).
*/
private final UpdateSequenceListener updateSequenceListener =
new UpdateSequenceListener();
/**
* Constructs an instance of <code>KeySequenceTextField</code> with the
* text field to use. If the platform is carbon (MacOS X), then the font is
* set to be the same font used to display accelerators in the menus.
*
* @param wrappedText
* The text widget to wrap; must not be <code>null</code>.
*/
public KeySequenceText(Text wrappedText) {
text = wrappedText;
// Set the font if the platform is carbon.
if ("carbon".equals(SWT.getPlatform())) { //$NON-NLS-1$
// Don't worry about this font name here; it is the official menu
// font and point size on the Mac.
final Font font = new Font(text.getDisplay(), "Lucida Grande", 13, SWT.NORMAL); //$NON-NLS-1$
text.setFont(font);
text.addDisposeListener(new DisposeListener() {
public void widgetDisposed(DisposeEvent e) {
font.dispose();
}
});
}
// Add the key listener.
text.addListener(SWT.KeyUp, keyFilter);
text.addListener(SWT.KeyDown, keyFilter);
// Add the focus listener that attaches the global traversal filter.
text.addFocusListener(new TraversalFilterManager());
// Add an internal modify listener.
text.addModifyListener(updateSequenceListener);
}
/**
* Clears the text field and resets all the internal values.
*/
public void clear() {
keySequence = KeySequence.getInstance();
text.setText(EMPTY_STRING);
}
/**
* Removes the key strokes from the list corresponding the selection. If
* <code>allowIncomplete</code>, then invalid key sequences will be
* allowed (i.e., those with incomplete strokes in the non-terminal
* position). Otherwise, incomplete strokes will be removed. This modifies
* <code>keyStrokes</code> in place, and has no effect on the text widget
* this class wraps.
*
* @param keyStrokes
* The list of key strokes from which the selection should be
* removed; must not be <code>null</code>.
* @param allowIncomplete
* Whether incomplete strokes should be allowed to exist in the
* list after the deletion.
* @return The index at which a subsequent insert should occur. This index
* only has meaning to the <code>insertStrokeAt</code> method.
*/
private int deleteSelection(List keyStrokes, boolean allowIncomplete) {
// Get the current selection.
Point selection = text.getSelection();
int start = selection.x;
int end = selection.y;
/*
* Using the key sequence format method, discover the point at which
* adding key strokes passes or equals the start of the selection. In
* other words, find the first stroke that is part of the selection.
* Keep track of the text range under which the stroke appears (i.e.,
* startTextIndex->string.length() is the first selected stroke).
*/
String string = new String();
List currentStrokes = new ArrayList();
Iterator keyStrokeItr = keyStrokes.iterator();
int startTextIndex = 0; // keeps track of the start of the stroke
while ((string.length() < start) && (keyStrokeItr.hasNext())) {
startTextIndex = string.length();
currentStrokes.add(keyStrokeItr.next());
string = KeySequence.getInstance(currentStrokes).format();
}
/*
* If string.length() == start, then the cursor is positioned between
* strokes (i.e., selection is outside of a stroke).
*/
int startStrokeIndex;
if (string.length() == start) {
startStrokeIndex = currentStrokes.size();
} else {
startStrokeIndex = currentStrokes.size() - 1;
}
/*
* Check to see if the cursor is only positioned, rather than actually
* selecting something. We only need to compute the end if there is a
* selection.
*/
int endStrokeIndex;
if (start == end) {
return startStrokeIndex;
} else {
while ((string.length() < end) && (keyStrokeItr.hasNext())) {
currentStrokes.add(keyStrokeItr.next());
string = KeySequence.getInstance(currentStrokes).format();
}
endStrokeIndex = currentStrokes.size() - 1;
if (endStrokeIndex < 0) {
endStrokeIndex = 0;
}
}
/*
* Remove the strokes that are touched by the selection. Keep track of
* the first stroke removed.
*/
KeyStroke startStroke = (KeyStroke) keyStrokes.get(startStrokeIndex);
while (startStrokeIndex <= endStrokeIndex) {
keyStrokes.remove(startStrokeIndex);
endStrokeIndex--;
}
/*
* Allow the first stroke removed to be replaced by an incomplete
* stroke.
*/
if (allowIncomplete) {
SortedSet modifierKeys = new TreeSet(startStroke.getModifierKeys());
KeyStroke incompleteStroke =
KeyStroke.getInstance(modifierKeys, null);
int incompleteStrokeLength = incompleteStroke.format().length();
if ((startTextIndex + incompleteStrokeLength) <= start) {
keyStrokes.add(startStrokeIndex, incompleteStroke);
}
}
return startStrokeIndex;
}
/**
* An accessor for the <code>KeySequence</code> that corresponds to the
* current state of the text field. This includes incomplete strokes.
*
* @return The key sequence representation; never <code>null</code>.
*/
public KeySequence getKeySequence() {
return keySequence;
}
/**
* An accessor for the underlying text widget's contents.
*
* @return The text contents of this entry; never <code>null</code>.
*/
private String getText() {
return text.getText();
}
/**
* Tests whether the current key sequence has a stroke with no natural key.
*
* @return <code>true</code> is there is an incomplete stroke; <code>false</code>
* otherwise.
*/
private boolean hasIncompleteStroke() {
return !keySequence.isComplete();
}
/**
* Tests whether the current text widget has some text selection.
*
* @param <code>true</code> if the number of selected characters it greater
* than zero; <code>false</code> otherwise.
*/
private boolean hasSelection() {
return (text.getSelectionCount() > 0);
}
/**
* Inserts the key stroke at the current insertion point. This does a
* regular delete and insert, as if the key had been pressed.
*
* @param stroke
* The key stroke to insert; must not be <code>null</code>.
*/
public void insert(KeyStroke stroke) {
if (!stroke.isComplete()) {
return;
}
// Copy the key strokes in the current key sequence.
List keyStrokes = new ArrayList(getKeySequence().getKeyStrokes());
if ((hasIncompleteStroke()) && (!keyStrokes.isEmpty())) {
keyStrokes.remove(keyStrokes.size() - 1);
}
int index = deleteSelection(keyStrokes, false);
insertStrokeAt(keyStrokes, stroke, index);
keyFilter.clearInsertionIndex();
setKeySequence(KeySequence.getInstance(keyStrokes));
}
/**
* Inserts the stroke at the given index in the list of strokes. If the
* stroke currently at that index is incomplete, then it tries to merge the
* two strokes. If merging is a complete failure (unlikely), then it will
* simply overwrite the incomplete stroke. If the stroke at the index is
* complete, then it simply inserts the stroke independently.
*
* @param keyStrokes
* The list of key strokes in which the key stroke should be
* appended; must not be <code>null</code>.
* @param stroke
* The stroke to insert; should not be <code>null</code>.
* @param index
* The index at which to insert; must be a valid index into the
* list of key strokes.
*/
private void insertStrokeAt(List keyStrokes, KeyStroke stroke, int index) {
KeyStroke currentStroke =
(index >= keyStrokes.size())
? null
: (KeyStroke) keyStrokes.get(index);
if ((currentStroke != null) && (!currentStroke.isComplete())) {
SortedSet modifierKeys =
new TreeSet(currentStroke.getModifierKeys());
NaturalKey naturalKey = stroke.getNaturalKey();
modifierKeys.addAll(stroke.getModifierKeys());
keyStrokes.remove(index);
keyStrokes.add(
index,
KeyStroke.getInstance(modifierKeys, naturalKey));
} else {
keyStrokes.add(index, stroke);
}
}
/**
* Tests whether the cursor is in the last position. This means that the
* selection extends to the last position.
*
* @return <code>true</code> if the selection extends to the last
* position; <code>false</code> otherwise.
*/
private boolean isCursorInLastPosition() {
return (text.getSelection().y >= getText().length());
}
/**
* <p>
* A mutator for the key sequence stored within this widget. The text and
* caret position are updated.
* </p>
* <p>
* All sequences are limited to maxStrokes number of strokes in length. If
* there are already that number of strokes, then it does not show
* incomplete strokes, and does not keep track of them.
* </p>
*
* @param newKeySequence
* The new key sequence for this widget; may be <code>null</code>
* if none.
*/
public void setKeySequence(KeySequence newKeySequence) {
keySequence = newKeySequence;
// Trim any extra strokes.
if (maxStrokes != INFINITE) {
List keyStrokes = new ArrayList(keySequence.getKeyStrokes());
int keyStrokesSize = keyStrokes.size();
for (int i = keyStrokesSize - 1; i >= maxStrokes; i--) {
keyStrokes.remove(i);
}
keySequence = KeySequence.getInstance(keyStrokes);
}
// Check to see if the text has changed.
String currentString = getText();
String newString = keySequence.format();
if (!currentString.equals(newString)) {
// We need to update the text
text.removeModifyListener(updateSequenceListener);
text.setText(keySequence.format());
text.addModifyListener(updateSequenceListener);
text.setSelection(getText().length());
}
}
/**
* Returns the maximum number of strokes that are permitted in this widget
* at one time.
*
* @return The maximum number of strokes; will be a positive integer or
* <code>INFINITE</code>.
*/
public int getKeyStrokeLimit() {
return maxStrokes;
}
/**
* A mutator for the maximum number of strokes that are permitted in this
* widget at one time.
*
* @param keyStrokeLimit
* The maximum number of strokes; must be a positive integer or
* <code>INFINITE</code>.
*/
public void setKeyStrokeLimit(int keyStrokeLimit) {
if (keyStrokeLimit > 0 || keyStrokeLimit == INFINITE)
this.maxStrokes = keyStrokeLimit;
else
throw new IllegalArgumentException();
// Make sure we are obeying the new limit.
setKeySequence(getKeySequence());
}
}