blob: 094afbbcae32273f1ef9a9b1c7bb732741ff0f5c [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2018 vogella GmbH and others.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Fabian Pfaff <fabian.pfaff@vogella.com> - initial API and implementation
*******************************************************************************/
package org.eclipse.ui.internal.ide;
import static java.util.stream.Collectors.toList;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.eclipse.jface.bindings.keys.KeyStroke;
import org.eclipse.jface.bindings.keys.ParseException;
import org.eclipse.jface.fieldassist.ComboContentAdapter;
import org.eclipse.jface.fieldassist.ContentProposal;
import org.eclipse.jface.fieldassist.ContentProposalAdapter;
import org.eclipse.jface.fieldassist.ControlDecoration;
import org.eclipse.jface.fieldassist.FieldDecoration;
import org.eclipse.jface.fieldassist.FieldDecorationRegistry;
import org.eclipse.jface.fieldassist.IContentProposal;
import org.eclipse.jface.fieldassist.IContentProposalListener2;
import org.eclipse.jface.fieldassist.IContentProposalProvider;
import org.eclipse.jface.fieldassist.IControlContentAdapter;
import org.eclipse.osgi.util.NLS;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.KeyEvent;
import org.eclipse.swt.events.KeyListener;
import org.eclipse.swt.events.MouseListener;
import org.eclipse.swt.widgets.Combo;
import org.eclipse.swt.widgets.Control;
import org.eclipse.ui.internal.WorkbenchMessages;
/**
* Adds content assist to a Combo, that is intended to be used to choose a
* directory.
*/
public class DirectoryProposalContentAssist {
private static class FileNameSubstringMatchContentProposalProvider implements IContentProposalProvider {
private List<String> proposals = Collections.emptyList();
/**
* Returns an array of valid proposals filtered by substring matching of the
* fileName.
*
* @param contents the current contents of the text field
* @param position the current position of the cursor in the contents
*
* @return the array of {@link IContentProposal} that represent valid proposals
* for the field.
*/
@Override
public IContentProposal[] getProposals(String contents, int position) {
String substring = contents.substring(0, position);
// only match fileName as the folder part will be equal anyway and this makes
// substring matching easier
Pattern pattern = Pattern.compile(substring,
Pattern.LITERAL | Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
return proposals.stream()
.filter(proposal -> proposal.length() >= substring.length() && pattern.matcher(proposal).find())
.map(ContentProposal::new)
.toArray(IContentProposal[]::new);
}
/**
* Set the Strings to be used as content proposals.
*
* @param proposals the Strings to be used as proposals.
*/
public void setProposals(List<String> proposals) {
this.proposals = proposals;
}
}
private static class OpenableContentProposalAdapter extends ContentProposalAdapter {
private static final String CONTENT_ASSIST_DECORATION_ID = "org.eclipse.ui.internal.ide.DirectoryProposalContentAssist$ReopenableContentProposalAdapter"; //$NON-NLS-1$
public OpenableContentProposalAdapter(Control control, IControlContentAdapter controlContentAdapter,
IContentProposalProvider proposalProvider, KeyStroke keyStroke, char[] autoActivationCharacters) {
super(control, controlContentAdapter, proposalProvider, keyStroke, autoActivationCharacters);
installContentProposalFieldDecoration(control, keyStroke);
}
/**
* Installs a field decoration that shows the user that the control supports
* content assist.
*
* @param control the control that supports content assist
* @param keyStroke the key stroke to be shown in the field decoration hover
* text
*/
private void installContentProposalFieldDecoration(Control control, KeyStroke keyStroke) {
ControlDecoration decoration = new ControlDecoration(control, SWT.TOP | SWT.LEFT);
decoration.setShowOnlyOnFocus(true);
FieldDecoration dec = getContentAssistFieldDecoration(keyStroke);
decoration.setImage(dec.getImage());
decoration.setDescriptionText(dec.getDescription());
}
/**
* Return the field decoration that should be used to indicate that content
* assist is available for a field. Ensure that the decoration text includes the
* correct key binding.
*
* @param keyStroke the key stroke to be shown in the hover text
* @return the {@link FieldDecoration} that should be used to show content
* assist.
**/
private FieldDecoration getContentAssistFieldDecoration(KeyStroke keyStroke) {
FieldDecorationRegistry registry = FieldDecorationRegistry.getDefault();
String decId = CONTENT_ASSIST_DECORATION_ID + keyStroke;
FieldDecoration dec = registry.getFieldDecoration(decId);
// If there is not one, base ours on the standard JFace one.
if (dec == null) {
FieldDecoration originalDec = registry.getFieldDecoration(FieldDecorationRegistry.DEC_CONTENT_PROPOSAL);
registry.registerFieldDecoration(decId, null, originalDec.getImage());
dec = registry.getFieldDecoration(decId);
}
dec.setDescription(NLS.bind(WorkbenchMessages.ContentAssist_Cue_Description_Key, keyStroke));
return dec;
}
@Override
public void openProposalPopup() {
super.openProposalPopup();
}
}
private class DirectoryProposalAutoCompleteField {
private FileNameSubstringMatchContentProposalProvider proposalProvider;
private OpenableContentProposalAdapter adapter;
public DirectoryProposalAutoCompleteField(Control control, IControlContentAdapter controlContentAdapter) {
proposalProvider = new FileNameSubstringMatchContentProposalProvider();
KeyStroke triggeringKeyStroke = safeKeyStroke("Ctrl+Space"); //$NON-NLS-1$
String backspace = "\b"; //$NON-NLS-1$
String delete = "\u007F"; //$NON-NLS-1$
char[] autoactivationChars = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + backspace //$NON-NLS-1$
+ delete).toCharArray();
adapter = new OpenableContentProposalAdapter(control, controlContentAdapter, proposalProvider,
triggeringKeyStroke, autoactivationChars);
adapter.setPropagateKeys(true);
adapter.setProposalAcceptanceStyle(ContentProposalAdapter.PROPOSAL_REPLACE);
}
private KeyStroke safeKeyStroke(String keyStrokePattern) {
try {
return KeyStroke.getInstance(keyStrokePattern);
} catch (ParseException e) {
return null;
}
}
/**
* Sets the given proposals on the content provider and forces the proposal
* pop-up to refresh its content.
*
* @param proposals the proposals to set
* @param openProposalPopup if the proposal pop-up should be opened
*/
public void refreshProposals(List<String> proposals, boolean openProposalPopup) {
proposalProvider.setProposals(proposals);
adapter.refresh();
if (openProposalPopup) {
adapter.openProposalPopup();
}
}
}
private Path lastDir;
private DirectoryProposalAutoCompleteField autoCompleteField;
private Combo directoryCombo;
/**
* The {@link ContentProposalAdapter} closes the proposal pop-up when the
* proposals are empty. This remembers the popup state and reopen the popup
* after the proposals have been updated by the asynchronous job.
*/
private boolean popupActivated = false;
private List<CompletableFuture<Void>> proposalUpdateFutures = Collections.synchronizedList(new ArrayList<>());
/**
* Applies auto-completion to a Combo that is intended to be used to choose a
* directory. Proposals are triggered automatically, but can also be triggered
* by pressing Ctrl+Space.
*
* @param combo the Combo that gets the auto-completion applied
*/
public void apply(Combo combo) {
directoryCombo = combo;
autoCompleteField = new DirectoryProposalAutoCompleteField(directoryCombo, new ComboContentAdapter());
getContentProposalAdapter().addContentProposalListener(e -> updateProposals(directoryCombo.getText(), false));
getContentProposalAdapter().addContentProposalListener(new IContentProposalListener2() {
@Override
public void proposalPopupOpened(ContentProposalAdapter adapter) {
popupActivated = true;
}
@Override
public void proposalPopupClosed(ContentProposalAdapter adapter) {
// do nothing on purpose
}
});
directoryCombo.addModifyListener(
e -> updateProposals(directoryCombo.getText().substring(0, directoryCombo.getCaretPosition()), true));
directoryCombo.addKeyListener(KeyListener.keyPressedAdapter(e -> {
if (e.keyCode == SWT.ESC) {
popupActivated = false;
}
}));
// use key release because otherwise the caret position is not yet updated
directoryCombo.addKeyListener(KeyListener.keyReleasedAdapter(e -> {
if (isTraverse(e)) {
int caretPosition = directoryCombo.getCaretPosition();
updateProposals(directoryCombo.getText().substring(0, caretPosition), popupActivated);
}
}));
directoryCombo.addMouseListener(MouseListener.mouseUpAdapter(e -> {
int caretPosition = ((Combo) e.getSource()).getCaretPosition();
updateProposals(directoryCombo.getText().substring(0, caretPosition), false);
}));
}
private boolean isTraverse(KeyEvent e) {
return e.keyCode == SWT.ARROW_LEFT || e.keyCode == SWT.ARROW_RIGHT
|| e.keyCode == SWT.HOME || e.keyCode == SWT.END;
}
/**
* Checks if the directory in the combo has changed. If the directory has
* changed the proposals get asynchronously updated with the subfolders of the
* new directory. If the current content of the combo is not a valid directory
* the proposals get cleared.
*/
private void updateProposals(String textFromCombo, boolean openProposalPopup) {
Path dir = pathWithoutFileName(textFromCombo);
if (dir != null && dir.equals(lastDir)) {
if (openProposalPopup) {
autoCompleteField.adapter.openProposalPopup();
}
return;
}
if (dir == null || !safeIsDirectory(dir)) {
updateProposals(Collections.emptyList(), false);
lastDir = null;
return;
}
lastDir = dir;
CompletableFuture<Void> completableFuture = CompletableFuture
.runAsync(() -> updateProposals(retrieveDirectoriesIn(dir), openProposalPopup));
proposalUpdateFutures.add(completableFuture);
proposalUpdateFutures.removeIf(CompletableFuture::isDone);
}
private List<String> retrieveDirectoriesIn(Path dir) {
try (Stream<Path> files = Files.list(dir)) {
return filterPaths(files).sorted().collect(toList());
} catch (IOException ex) {
return new ArrayList<>();
}
}
private void updateProposals(List<String> proposals, boolean openProposalPopup) {
directoryCombo.getDisplay().syncExec(() -> autoCompleteField.refreshProposals(proposals, openProposalPopup));
}
private Path pathWithoutFileName(String inputPath) {
int lastIndex = inputPath.lastIndexOf(File.separatorChar);
if (separatorNotFound(lastIndex)) {
return null;
}
return safeGetPath(removeFileName(inputPath, lastIndex));
}
private boolean separatorNotFound(int lastIndex) {
return lastIndex < 0;
}
private String removeFileName(String text, int lastIndex) {
if (lastIndex == 0) {
return File.separator;
}
return text.substring(0, lastIndex + 1);
}
private Path safeGetPath(String text) {
try {
return Paths.get(text);
} catch (InvalidPathException ex) {
return null;
}
}
private boolean safeIsDirectory(Path dir) {
try {
return dir.toFile().isDirectory();
} catch (SecurityException ex) {
return false;
}
}
/**
* Filters out files and hidden directories.
*
* @param paths the Paths to filter
* @return the filtered paths
*/
private Stream<String> filterPaths(Stream<Path> paths) {
return paths.filter(path -> {
try {
return safeIsDirectory(path) && !Files.isHidden(path);
} catch (IOException e) {
return false;
}
}).map(path -> path.toString() + File.separator);
}
public DirectoryProposalAutoCompleteField getAutoCompleteField() {
return autoCompleteField;
}
public ContentProposalAdapter getContentProposalAdapter() {
return autoCompleteField.adapter;
}
/**
* Wait until the asynchronous proposal refresh is finished. This method is
* intended to by used by tests.
*
* @param timeout timeout in milliseconds
* @throws TimeoutException if the wait timed out
* @throws ExecutionException if the future completed exceptionally
* @throws InterruptedException if the current thread was interrupted while
* waiting
*/
protected void wait(int timeout) throws InterruptedException, ExecutionException, TimeoutException {
CompletableFuture.allOf(proposalUpdateFutures.toArray(new CompletableFuture[proposalUpdateFutures.size()]))
.get(timeout, TimeUnit.MILLISECONDS);
}
}