blob: fa5399e0bed145f0651b97c77b7c18c9ac31ffe7 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2005 IBM Corporation and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* IBM Corporation - initial API and implementation
******************************************************************************/
package org.eclipse.jface.fieldassist;
import java.util.ArrayList;
import org.eclipse.core.runtime.Assert;
import org.eclipse.jface.dialogs.Dialog;
import org.eclipse.jface.dialogs.PopupDialog;
import org.eclipse.jface.viewers.ILabelProvider;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.DisposeEvent;
import org.eclipse.swt.events.DisposeListener;
import org.eclipse.swt.events.FocusAdapter;
import org.eclipse.swt.events.FocusEvent;
import org.eclipse.swt.events.SelectionEvent;
import org.eclipse.swt.events.SelectionListener;
import org.eclipse.swt.graphics.FontMetrics;
import org.eclipse.swt.graphics.GC;
import org.eclipse.swt.graphics.Image;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Control;
import org.eclipse.swt.widgets.Event;
import org.eclipse.swt.widgets.Listener;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.Table;
import org.eclipse.swt.widgets.TableItem;
import org.eclipse.swt.widgets.Text;
/**
* A lightweight popup used to show content proposals for a text field. If
* additional information exists for a proposal, then selecting that proposal
* will result in the information being displayed in a secondary popup.
*
* <p>
* This API is considered experimental. It is still evolving during 3.2 and is
* subject to change. It is being released to obtain feedback from early
* adopters. For now, there is limited API to control how the popup is sized or
* placed relative to the control, or whether it is resizable. The intention is
* that standard techniques will be implemented to "do the right thing"
* depending on the associated control's size/location and the content size, and
* API added if we discover there are additional choices to be made.
*
* @since 3.2
*/
public class ContentProposalPopup extends PopupDialog {
/*
* Set to <code>true</code> to use a Table with SWT.VIRTUAL. This is a
* workaround for https://bugs.eclipse.org/bugs/show_bug.cgi?id=98585#c40
* The corresponding SWT bug is
* https://bugs.eclipse.org/bugs/show_bug.cgi?id=90321
*/
private static final boolean USE_VIRTUAL = !"motif".equals(SWT.getPlatform()); //$NON-NLS-1$
/*
* The delay before showing a secondary popup.
*/
private static final int POPUP_DELAY = 500;
/*
* The character height hint for the popup.
*/
private static final int POPUP_CHARHEIGHT = 10;
/*
* Empty string.
*/
private static final String EMPTY = ""; //$NON-NLS-1$
/*
* The listener we will install on the target control.
*/
private final class TargetControlListener implements Listener {
// Key events from the control
public void handleEvent(Event e) {
if (!isValid())
return;
if (!(e.type == SWT.KeyDown || e.type == SWT.Traverse))
return;
// Traverse events will be blocked when the popup is open, but
// we will interpret their characters as navigation within the
// popup.
if (e.type == SWT.Traverse) {
e.detail = SWT.TRAVERSE_NONE;
e.doit = true;
}
char key = e.character;
// No character. Check for navigation keys.
if (key == 0) {
int newSelection = proposalTable.getSelectionIndex();
int visibleRows = (proposalTable.getSize().y / proposalTable
.getItemHeight()) - 1;
switch (e.keyCode) {
case SWT.ARROW_UP:
newSelection -= 1;
if (newSelection < 0)
newSelection = proposalTable.getItemCount() - 1;
break;
case SWT.ARROW_DOWN:
newSelection += 1;
if (newSelection > proposalTable.getItemCount() - 1)
newSelection = 0;
break;
case SWT.PAGE_DOWN:
newSelection += visibleRows;
if (newSelection >= proposalTable.getItemCount())
newSelection = proposalTable.getItemCount() - 1;
break;
case SWT.PAGE_UP:
newSelection -= visibleRows;
if (newSelection < 0)
newSelection = 0;
break;
case SWT.HOME:
newSelection = 0;
break;
case SWT.END:
newSelection = proposalTable.getItemCount() - 1;
break;
// Any unknown keycodes will cause the popup to close.
// Modifier keys are explicitly checked and ignored because
// they are not complete yet (no character).
default:
if (e.keyCode != SWT.CAPS_LOCK && e.keyCode != SWT.MOD1
&& e.keyCode != SWT.MOD2 && e.keyCode != SWT.MOD3
&& e.keyCode != SWT.MOD4)
close();
return;
}
// If any of these navigation events caused a new selection,
// then handle that now and return.
if (newSelection >= 0)
selectProposal(newSelection);
return;
}
// key != 0
// Check for special keys involved in cancelling, accepting, or
// filtering the proposals.
switch (key) {
case SWT.ESC: // Esc
e.doit = false;
close();
break;
case SWT.LF:
case SWT.CR:
e.doit = false;
Object p = getSelectedProposal();
if (p != null)
acceptCurrentProposal();
close();
break;
case SWT.TAB:
e.doit = false;
getShell().setFocus();
return;
case SWT.BS:
// Backspace should back out of any stored filter text
if (filter) {
filterText = filterText.substring(0,
filterText.length() - 1);
recomputeProposals(filterText);
return;
}
default:
// If the key is a defined unicode character, and not one of the
// special cases processed above, update the filter text and
// filter the proposals.
if (filter && Character.isDefined(key)) {
filterText = filterText + String.valueOf(key);
recomputeProposals(filterText);
}
break;
}
}
}
/*
* Internal class used to implement the secondary popup.
*/
private class InfoPopupDialog extends PopupDialog {
/*
* The text control that displays the text.
*/
private Text text;
/*
* The String shown in the popup.
*/
private String contents = EMPTY;
/*
* Construct an info-popup with the specified parent.
*/
InfoPopupDialog(Shell parent) {
super(parent, PopupDialog.HOVER_SHELLSTYLE, false, false, false,
false, null, null);
}
/*
* Create a text control for showing the info about a proposal.
*/
protected Control createDialogArea(Composite parent) {
text = new Text(parent, SWT.MULTI | SWT.READ_ONLY | SWT.WRAP
| SWT.NO_FOCUS);
// Use the compact margins employed by PopupDialog.
GridData gd = new GridData(GridData.BEGINNING | GridData.FILL_BOTH);
gd.horizontalIndent = PopupDialog.POPUP_HORIZONTALSPACING;
gd.verticalIndent = PopupDialog.POPUP_VERTICALSPACING;
text.setLayoutData(gd);
text.setText(contents);
// since SWT.NO_FOCUS is only a hint...
text.addFocusListener(new FocusAdapter() {
public void focusGained(FocusEvent event) {
ContentProposalPopup.this.close();
}
});
return text;
}
/*
* Adjust the bounds so that we appear adjacent to our parent shell
*/
protected void adjustBounds() {
Rectangle parentBounds = getParentShell().getBounds();
getShell().setBounds(
new Rectangle(parentBounds.x + parentBounds.width
+ PopupDialog.POPUP_HORIZONTALSPACING,
parentBounds.y + PopupDialog.POPUP_VERTICALSPACING,
parentBounds.width, parentBounds.height));
}
/*
* Set the text contents of the popup.
*/
void setContents(String newContents) {
if (newContents == null)
newContents = EMPTY;
this.contents = newContents;
if (text != null && !text.isDisposed()) {
text.setText(contents);
}
}
}
/*
* The adapter on the target control.
*/
private ContentProposalAdapter adapter;
/*
* The listener installed on the target control.
*/
private Listener controlListener;
/*
* The control on which we are proposing content.
*/
private Control control;
/*
* The table used to show the list of proposals.
*/
private Table proposalTable;
/*
* The provider of proposals and proposal descriptions
*/
private IContentProposalProvider proposalProvider;
/*
* The proposals to be shown (cached to avoid repeated requests).
*/
private IContentProposal[] proposals;
/*
* A label provider used to show the proposals in the table.
*/
private ILabelProvider labelProvider;
/*
* Secondary popup used to show detailed information about the selected
* proposal..
*/
private InfoPopupDialog infoPopup;
/*
* Filter text - tracked while popup is open, only if we are told to filter
*/
private String filterText = EMPTY;
/*
* Boolean that indicates whether we are filtering.
*/
private boolean filter = false;
/**
* Constructs a new instance of this popup, specifying the control for which
* this popup is showing content, and how the proposals should be obtained
* and displayed.
*
* @param adapter
* the content proposal adapter that opened this dialog.
* @param control
* the control whose content is being proposed.
* @param proposalProvider
* the provider of content proposals
* @param infoText
* Text to be shown in a lower info area, or <code>null</code>
* if there is no info area.
* @param labelProvider
* the {@link ILabelProvider} used to obtain visual content for
* the proposals. Must never be <code>null</code>.
* @param filter
* a boolean indicating whether proposals should be filtered as
* keys are typed in the popup.
*/
public ContentProposalPopup(ContentProposalAdapter adapter,
Control control, IContentProposalProvider proposalProvider,
String infoText, ILabelProvider labelProvider, boolean filter) {
super(control.getShell(), PopupDialog.INFOPOPUPRESIZE_SHELLSTYLE,
false, false, false, false, null, infoText);
this.adapter = adapter;
this.control = control;
this.labelProvider = labelProvider;
this.proposalProvider = proposalProvider;
this.proposals = getProposals(filterText);
this.filter = filter;
}
/*
* Creates the content area for the proposal popup. This creates a table and
* places it inside the composite. The table will contain a list of all the
* proposals.
*
* @param parent The parent composite to contain the dialog area; must not
* be <code>null</code>.
*/
protected final Control createDialogArea(final Composite parent) {
// Use virtual where appropriate (see flag definition).
if (USE_VIRTUAL) {
proposalTable = new Table(parent, SWT.H_SCROLL | SWT.V_SCROLL
| SWT.VIRTUAL);
Listener listener = new Listener() {
public void handleEvent(Event event) {
handleSetData(event);
}
};
proposalTable.addListener(SWT.SetData, listener);
} else {
proposalTable = new Table(parent, SWT.H_SCROLL | SWT.V_SCROLL);
}
// compute the proposals to force population of the table.
recomputeProposals(filterText);
proposalTable.setHeaderVisible(false);
proposalTable.addSelectionListener(new SelectionListener() {
public void widgetSelected(SelectionEvent e) {
// If a proposal has been selected, show it in the popup.
// Otherwise close the popup.
if (e.item == null) {
if (infoPopup != null)
infoPopup.close();
} else {
TableItem item = (TableItem) e.item;
IContentProposal proposal = (IContentProposal) item
.getData();
showProposalDescription(proposal.getDescription());
}
}
// Default selection was made. Accept the current proposal.
public void widgetDefaultSelected(SelectionEvent e) {
acceptCurrentProposal();
}
});
// Compute a height and width for the table.
GridData data = new GridData(GridData.FILL_BOTH);
GC gc = new GC(proposalTable);
gc.setFont(proposalTable.getFont());
FontMetrics fontMetrics = gc.getFontMetrics();
gc.dispose();
data.widthHint = control.getBounds().width;
data.heightHint = Dialog.convertHeightInCharsToPixels(fontMetrics,
POPUP_CHARHEIGHT);
proposalTable.setLayoutData(data);
return proposalTable;
}
/*
* (non-Javadoc)
*
* @see org.eclipse.jface.dialogs.PopupDialog.adjustBounds()
*/
protected void adjustBounds() {
// Get our control's location in display coordinates.
Point location = control.getDisplay().map(control.getParent(), null,
control.getLocation());
getShell().setLocation(location.x + 3,
location.y + control.getSize().y + 3);
}
/*
* Handle the set data event. Set the item data of the requested item to the
* corresponding proposal in the proposal cache.
*/
private void handleSetData(Event event) {
TableItem item = (TableItem) event.item;
int index = proposalTable.indexOf(item);
if (0 <= index && index < proposals.length) {
IContentProposal current = proposals[index];
item.setText(getString(current));
item.setImage(getImage(current));
item.setData(current);
} else {
// this should not happen, but does on win32
}
}
/*
* Caches the specified proposals and repopulates the table if it has been
* created.
*/
private void setProposals(IContentProposal[] newProposals) {
if (newProposals == null || newProposals.length == 0) {
newProposals = getEmptyProposalArray();
}
this.proposals = newProposals;
// If there is a table
if (isValid()) {
final int newSize = newProposals.length;
if (USE_VIRTUAL) {
// Set and clear the virtual table. Data will be
// provided in the SWT.SetData event handler.
proposalTable.setItemCount(newSize);
proposalTable.clearAll();
} else {
// Populate the table manually
proposalTable.setRedraw(false);
proposalTable.setItemCount(newSize);
TableItem[] items = proposalTable.getItems();
for (int i = 0; i < items.length; i++) {
TableItem item = items[i];
IContentProposal proposal = newProposals[i];
item.setText(getString(proposal));
item.setImage(getImage(proposal));
item.setData(proposal);
}
proposalTable.setRedraw(true);
}
// Default to the first selection if there is content.
if (newProposals.length > 0) {
selectProposal(0);
} else {
// No selection, close the secondary popup if it was open
if (infoPopup != null)
infoPopup.close();
}
}
}
/*
* Get the string for the specified proposal. Always return a String of some
* kind.
*/
private String getString(IContentProposal proposal) {
if (proposal == null)
return EMPTY;
if (labelProvider == null)
return proposal.getLabel() == null ? proposal.getContent()
: proposal.getLabel();
return labelProvider.getText(proposal);
}
/*
* Get the image for the specified proposal. If there is no image available,
* return null.
*/
private Image getImage(IContentProposal proposal) {
if (proposal == null || labelProvider == null)
return null;
return labelProvider.getImage(proposal);
}
/*
* Return an empty array. Used so that something always shows in the
* proposal popup, even if no proposal provider was specified.
*/
private IContentProposal[] getEmptyProposalArray() {
return new IContentProposal[0];
}
/*
* Add a listener to the target control. We monitor key and traverse events
* in the target control while we are open, in order to handle traversal and
* navigation requests.
*/
private void addControlListener() {
controlListener = new TargetControlListener();
control.addListener(SWT.KeyDown, controlListener);
control.addListener(SWT.Traverse, controlListener);
}
/*
* Remove any listeners installed on the control.
*/
private void removeControlListener() {
control.removeListener(SWT.KeyDown, controlListener);
control.removeListener(SWT.Traverse, controlListener);
}
/*
* Answer true if the popup is valid, which means the table has been created
* and not disposed.
*/
private boolean isValid() {
return proposalTable != null && !proposalTable.isDisposed();
}
/*
* Return the current selected proposal.
*/
private IContentProposal getSelectedProposal() {
if (isValid()) {
int i = proposalTable.getSelectionIndex();
if (proposals == null || i < 0 || i >= proposals.length)
return null;
return proposals[i];
}
return null;
}
/*
* Select the proposal at the given index.
*/
private void selectProposal(int index) {
Assert.isTrue(index >= 0, "Proposal index should never be negative"); //$NON-NLS-1$
if (!isValid() || proposals == null || index >= proposals.length)
return;
proposalTable.setSelection(index);
proposalTable.showSelection();
showProposalDescription(proposals[index].getDescription());
}
/**
* Opens this ContentProposalPopup. This method is extended in order to add
* the control listener when the popup is opened and to invoke the secondary
* popup if applicable.
*
* @return the return code
*
* @see org.eclipse.jface.window.Window#open()
*/
public int open() {
addControlListener();
int value = super.open();
showProposalDescription(getSelectedProposal().getDescription());
return value;
}
/**
* Closes this popup. This method is extended to remove the control
* listener.
*
* @return <code>true</code> if the window is (or was already) closed, and
* <code>false</code> if it is still open
*/
public boolean close() {
removeControlListener();
return super.close();
}
/*
* Get the proposals from the proposal provider. The provider may or may not
* filter the proposals based on the specified filter text.
*/
private IContentProposal[] getProposals(String filterString) {
if (proposalProvider == null)
return null;
int position = adapter.getControlContentAdapter().getCursorPosition(
adapter.getControl());
String contents = adapter.getControlContentAdapter()
.getControlContents(adapter.getControl());
IContentProposal[] proposals = proposalProvider.getProposals(contents,
position);
if (filter) {
return filterProposals(proposals, filterString);
}
return proposals;
}
/*
* Show the proposal description in a secondary popup.
*/
private void showProposalDescription(String description) {
// If we have not created an info popup yet, do so now.
if (infoPopup == null && description != null) {
// Create a thread that will sleep for the specified delay
// before creating the popup. We do not use Jobs since this
// code must be able to run independently of the Eclipse runtime.
Runnable runnable = new Runnable() {
public void run() {
try {
Thread.sleep(POPUP_DELAY);
} catch (InterruptedException e) {
}
if (!isValid())
return;
getShell().getDisplay().syncExec(new Runnable() {
public void run() {
// The selection may have changed by the time we
// open, so double check it.
IContentProposal p = getSelectedProposal();
if (p != null) {
if (infoPopup == null) {
infoPopup = new InfoPopupDialog(getShell());
infoPopup.open();
}
infoPopup.setContents(p.getDescription());
infoPopup.getShell().addDisposeListener(
new DisposeListener() {
public void widgetDisposed(
DisposeEvent event) {
infoPopup = null;
}
});
}
}
});
}
};
Thread t = new Thread(runnable);
t.start();
} else if (infoPopup != null) {
// We already have a popup, so just reset the contents.
infoPopup.setContents(description);
}
}
/*
* Accept the current proposal.
*/
private void acceptCurrentProposal() {
adapter.proposalAccepted(getSelectedProposal());
close();
}
/*
* Request the proposals from the proposal provider, and recompute any
* caches. Repopulate the popup if it is open.
*/
private void recomputeProposals(String filterText) {
setProposals(getProposals(filterText));
}
/*
* Filter the provided list of content proposals according to the filter
* text.
*/
private IContentProposal[] filterProposals(IContentProposal[] proposals,
String filterString) {
if (filterString.length() == 0)
return proposals;
// Check each string for a match. Use the string displayed to the user,
// not the proposal content.
ArrayList list = new ArrayList();
for (int i = 0; i < proposals.length; i++) {
String string = getString(proposals[i]);
if (string.length() >= filterString.length()
&& string.substring(0, filterString.length())
.equalsIgnoreCase(filterString))
list.add(proposals[i]);
}
return (IContentProposal[]) list.toArray(new IContentProposal[list
.size()]);
}
}