/******************************************************************************* * Copyright (c) 2014-2015 Codenvy, S.A. * 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: * Codenvy, S.A. - initial API and implementation *******************************************************************************/ package org.eclipse.che.ide.editor.orion.client; import com.google.gwt.user.client.ui.FlowPanel; import com.google.gwt.user.client.ui.RootPanel; import com.google.gwt.user.client.ui.Widget; import elemental.dom.Element; import elemental.dom.Node; import elemental.events.CustomEvent; import elemental.events.Event; import elemental.events.EventListener; import elemental.events.EventTarget; import elemental.events.KeyboardEvent; import elemental.events.MouseEvent; import elemental.html.SpanElement; import elemental.html.Window; import com.google.gwt.core.client.Scheduler; import com.google.gwt.dom.client.Style; import com.google.gwt.event.dom.client.KeyCodes; import com.google.gwt.user.client.Timer; import com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.AssistedInject; import org.eclipse.che.ide.api.texteditor.HandlesUndoRedo; import org.eclipse.che.ide.api.texteditor.UndoableEditor; import org.eclipse.che.ide.editor.orion.client.jso.OrionKeyModeOverlay; import org.eclipse.che.ide.editor.orion.client.jso.OrionModelChangedEventOverlay; import org.eclipse.che.ide.editor.orion.client.jso.OrionPixelPositionOverlay; import org.eclipse.che.ide.editor.orion.client.jso.OrionTextViewOverlay; import org.eclipse.che.ide.jseditor.client.codeassist.Completion; import org.eclipse.che.ide.jseditor.client.codeassist.CompletionProposal; import org.eclipse.che.ide.jseditor.client.codeassist.CompletionProposalExtension; import org.eclipse.che.ide.jseditor.client.events.CompletionRequestEvent; import org.eclipse.che.ide.jseditor.client.popup.PopupResources; import org.eclipse.che.ide.jseditor.client.text.LinearRange; import org.eclipse.che.ide.util.dom.Elements; import org.eclipse.che.ide.util.loging.Log; import static elemental.css.CSSStyleDeclaration.Unit.PX; import java.util.List; /** * @author Evgen Vidolob * @author Vitaliy Guliy */ public class ContentAssistWidget implements EventListener { /** * Custom event type. */ private static final String CUSTOM_EVT_TYPE_VALIDATE = "itemvalidate"; private static final String DOCUMENTATION = "documentation"; private final PopupResources popupResources; /** The related editor. */ private final OrionEditorWidget textEditor; private OrionKeyModeOverlay assistMode; /** The main element for the popup. */ private final Element popupElement; private final Element popupBodyElement; /** The list (ul) element for the popup. */ private final Element listElement; private final EventListener popupListener; private boolean visible = false; private boolean insert = true; /** * The previously focused element. */ private Element selectedElement; private FlowPanel docPopup; private OrionTextViewOverlay.EventHandler<OrionModelChangedEventOverlay> handler; @AssistedInject public ContentAssistWidget(final PopupResources popupResources, @Assisted final OrionEditorWidget textEditor, @Assisted OrionKeyModeOverlay assistMode) { this.popupResources = popupResources; this.textEditor = textEditor; this.assistMode = assistMode; popupElement = Elements.createDivElement(popupResources.popupStyle().popup()); Element headerElement = Elements.createDivElement(popupResources.popupStyle().header()); headerElement.setInnerText("Proposals:"); popupElement.appendChild(headerElement); popupBodyElement = Elements.createDivElement(popupResources.popupStyle().body()); popupElement.appendChild(popupBodyElement); listElement = Elements.createUListElement(); popupBodyElement.appendChild(listElement); docPopup = new FlowPanel(); docPopup.setStyleName(popupResources.popupStyle().popup()); docPopup.setSize("370px", "180px"); popupListener = new EventListener() { @Override public void handleEvent(final Event evt) { if (evt instanceof MouseEvent) { final MouseEvent mouseEvent = (MouseEvent)evt; final EventTarget target = mouseEvent.getTarget(); if (target instanceof Element) { final Element elementTarget = (Element)target; if (elementTarget.equals(docPopup.getElement()) && docPopup.isVisible()) { return; } if (!ContentAssistWidget.this.popupElement.contains(elementTarget)) { hide(); evt.preventDefault(); } } } // else won't happen } }; handler = new OrionTextViewOverlay.EventHandler<OrionModelChangedEventOverlay>() { @Override public void onEvent(OrionModelChangedEventOverlay event) { callCodeAssistTimer.cancel(); callCodeAssistTimer.schedule(250); } }; } private Timer callCodeAssistTimer = new Timer() { @Override public void run() { textEditor.getDocument().getDocumentHandle().getDocEventBus().fireEvent(new CompletionRequestEvent()); } }; public void validateItem(boolean replace) { this.insert = replace; selectedElement.dispatchEvent(createValidateEvent(CUSTOM_EVT_TYPE_VALIDATE)); } /** * * @param eventType * @return */ private native CustomEvent createValidateEvent(String eventType) /*-{ return new CustomEvent(eventType); }-*/; /** * Appends new proposal item to the popup * * @param proposal */ private void addProposalPopupItem(final CompletionProposal proposal) { final Element element = Elements.createLiElement(popupResources.popupStyle().item()); final Element icon = Elements.createDivElement(popupResources.popupStyle().icon()); if (proposal.getIcon() != null && proposal.getIcon().getSVGImage() != null) { icon.appendChild((Node)proposal.getIcon().getSVGImage().getElement()); } else if (proposal.getIcon() != null && proposal.getIcon().getImage() != null) { icon.appendChild((Node)proposal.getIcon().getImage().getElement()); } element.appendChild(icon); final SpanElement label = Elements.createSpanElement(popupResources.popupStyle().label()); label.setInnerHTML(proposal.getDisplayString()); element.appendChild(label); element.setTabIndex(1); // add item to the popup listElement.appendChild(element); final EventListener validateListener = new EventListener() { @Override public void handleEvent(final Event evt) { CompletionProposal.CompletionCallback callback = new CompletionProposal.CompletionCallback() { @Override public void onCompletion(final Completion completion) { HandlesUndoRedo undoRedo = null; UndoableEditor undoableEditor = ContentAssistWidget.this.textEditor; undoRedo = undoableEditor.getUndoRedo(); try { if (undoRedo != null) { undoRedo.beginCompoundChange(); } completion.apply(textEditor.getDocument()); final LinearRange selection = completion.getSelection(textEditor.getDocument()); if (selection != null) { textEditor.getDocument().setSelectedRange(selection, true); } } catch (final Exception e) { Log.error(getClass(), e); } finally { if (undoRedo != null) { undoRedo.endCompoundChange(); } } } }; if (proposal instanceof CompletionProposalExtension) { ((CompletionProposalExtension)proposal).getCompletion(insert, callback); } else { proposal.getCompletion(callback); } hide(); } }; element.addEventListener(Event.DBLCLICK, validateListener, false); element.addEventListener(CUSTOM_EVT_TYPE_VALIDATE, validateListener, false); element.addEventListener(Event.CLICK, new EventListener() { @Override public void handleEvent(Event event) { selectElement(element); } }, false); element.addEventListener(DOCUMENTATION, new EventListener() { @Override public void handleEvent(Event event) { Widget info = proposal.getAdditionalProposalInfo(); if (info != null) { docPopup.clear(); docPopup.add(info); if (docPopup.isAttached()) { return; } docPopup.getElement().getStyle().setLeft(popupElement.getOffsetLeft() + popupElement.getOffsetWidth() + 3, Style.Unit.PX); docPopup.getElement().getStyle().setTop(popupElement.getOffsetTop(), Style.Unit.PX); RootPanel.get().add(docPopup); docPopup.getElement().getStyle().setOpacity(1); } } }, false); } private void addPopupEventListeners() { Elements.getDocument().addEventListener(Event.MOUSEDOWN, this.popupListener, false); textEditor.getTextView().addKeyMode(assistMode); // add key event listener on popup textEditor.getTextView().setAction("cheContentAssistCancel", new Action() { @Override public void onAction() { hide(); } }); textEditor.getTextView().setAction("cheContentAssistApply", new Action() { @Override public void onAction() { validateItem(true); } }); textEditor.getTextView().setAction("cheContentAssistPreviousProposal", new Action() { @Override public void onAction() { selectPrevious(); } }); textEditor.getTextView().setAction("cheContentAssistNextProposal", new Action() { @Override public void onAction() { selectNext(); } }); textEditor.getTextView().setAction("cheContentAssistNextPage", new Action() { @Override public void onAction() { throw new UnsupportedOperationException("cheContentAssistNextPage"); } }); textEditor.getTextView().setAction("cheContentAssistPreviousPage", new Action() { @Override public void onAction() { throw new UnsupportedOperationException("cheContentAssistPreviousPage"); } }); textEditor.getTextView().setAction("cheContentAssistEnd", new Action() { @Override public void onAction() { selectElement(listElement.getLastElementChild()); } }); textEditor.getTextView().setAction("cheContentAssistHome", new Action() { @Override public void onAction() { selectElement(listElement.getFirstElementChild()); } }); textEditor.getTextView().setAction("cheContentAssistTab", new Action() { @Override public void onAction() { validateItem(false); } }); textEditor.getTextView().addEventListener("ModelChanging", handler); listElement.addEventListener(Event.KEYDOWN, this, false); } private void removePopupEventListeners() { /* Remove popup listeners. */ textEditor.getTextView().removeKeyMode(assistMode); textEditor.getTextView().removeEventListener("ModelChanging", handler, false); // remove the keyboard listener listElement.removeEventListener(Event.KEYDOWN, this, false); // remove the mouse listener Elements.getDocument().removeEventListener(Event.MOUSEDOWN, this.popupListener); } private void selectPrevious() { Element previousElement = selectedElement.getPreviousElementSibling(); if (previousElement != null) { selectElement(previousElement); } else { selectElement(listElement.getLastElementChild()); } } private void selectNext() { Element nextElement = selectedElement.getNextElementSibling(); if (nextElement != null) { selectElement(nextElement); } else { selectElement(listElement.getFirstElementChild()); } } private void selectElement(Element element) { if (selectedElement != null) { selectedElement.removeAttribute("selected"); } if (docPopup.isAttached()) { if (element != selectedElement) { element.dispatchEvent(createValidateEvent(DOCUMENTATION)); } } else { showDocTimer.cancel(); showDocTimer.schedule(1500); } selectedElement = element; selectedElement.setAttribute("selected", "true"); if (selectedElement.getOffsetTop() < this.popupBodyElement.getScrollTop()) { selectedElement.scrollIntoView(true); } else if ((selectedElement.getOffsetTop() + selectedElement.getOffsetHeight()) > (this.popupBodyElement.getScrollTop() + this.popupBodyElement.getClientHeight())) { selectedElement.scrollIntoView(false); } } private Timer showDocTimer = new Timer() { @Override public void run() { if (selectedElement != null) { selectedElement.dispatchEvent(createValidateEvent(DOCUMENTATION)); } } }; /** * Displays assist popup relative to the current cursor position. * * @param proposals proposals to display */ public void show(final List<CompletionProposal> proposals) { OrionTextViewOverlay textView = textEditor.getTextView(); OrionPixelPositionOverlay caretLocation = textView.getLocationAtOffset(textView.getCaretOffset()); caretLocation.setY(caretLocation.getY() + textView.getLineHeight()); caretLocation = textView.convert(caretLocation, "document", "page"); /** The fastest way to remove element children. Clear and add items. */ listElement.setInnerHTML(""); /* Display an empty popup when it is nothing to show. */ if (proposals == null || proposals.isEmpty()) { final Element emptyElement = Elements.createLiElement(popupResources.popupStyle().item()); emptyElement.setTextContent("No proposals"); listElement.appendChild(emptyElement); return; } /* Add new popup items. */ for (CompletionProposal proposal : proposals) { addProposalPopupItem(proposal); } /* Reset popup dimensions and show. */ popupElement.getStyle().setLeft(caretLocation.getX(), PX); popupElement.getStyle().setTop(caretLocation.getY(), PX); popupElement.getStyle().setWidth("400px"); popupElement.getStyle().setHeight("200px"); popupElement.getStyle().setOpacity(0); Elements.getDocument().getBody().appendChild(this.popupElement); Scheduler.get().scheduleDeferred(new Scheduler.ScheduledCommand() { @Override public void execute() { popupElement.getStyle().setOpacity(1); } }); /* Correct popup position (wants to be refactored) */ final Window window = Elements.getWindow(); final int viewportWidth = window.getInnerWidth(); final int viewportHeight = window.getInnerHeight(); int spaceBelow = viewportHeight - caretLocation.getY(); if (this.popupElement.getOffsetHeight() > spaceBelow) { // Check if div is too large to fit above int spaceAbove = caretLocation.getY() - textView.getLineHeight(); if (this.popupElement.getOffsetHeight() > spaceAbove) { // Squeeze the div into the larger area if (spaceBelow > spaceAbove) { this.popupElement.getStyle().setProperty("maxHeight", spaceBelow + "px"); } else { this.popupElement.getStyle().setProperty("maxHeight", spaceAbove + "px"); this.popupElement.getStyle().setTop("0"); } } else { // Put the div above the line this.popupElement.getStyle() .setTop((caretLocation.getY() - this.popupElement.getOffsetHeight() - textView.getLineHeight()) + "px"); this.popupElement.getStyle().setProperty("maxHeight", spaceAbove + "px"); } } else { this.popupElement.getStyle().setProperty("maxHeight", spaceBelow + "px"); } if (caretLocation.getX() + this.popupElement.getOffsetWidth() > viewportWidth) { int leftSide = viewportWidth - this.popupElement.getOffsetWidth(); if (leftSide < 0) { leftSide = 0; } this.popupElement.getStyle().setLeft(leftSide + "px"); this.popupElement.getStyle().setProperty("maxWidth", (viewportWidth - leftSide) + "px"); } else { this.popupElement.getStyle().setProperty("maxWidth", viewportWidth + caretLocation.getX() + "px"); } /* Don't attach handlers twice. Visible popup must already their attached. */ if (!visible) { addPopupEventListeners(); } /* Indicates the codeassist is visible. */ visible = true; if (docPopup.isAttached()) { docPopup.getElement().getStyle().setOpacity(0); new Timer() { @Override public void run() { docPopup.removeFromParent(); showDocTimer.schedule(1500); } }.schedule(250); } /* Select first row. */ selectElement(listElement.getFirstElementChild()); } /** * Hides the popup and displaying javadoc. */ public void hide() { if (docPopup.isAttached()) { docPopup.getElement().getStyle().setOpacity(0); new Timer() { @Override public void run() { docPopup.removeFromParent(); } }.schedule(250); } popupElement.getStyle().setOpacity(0); new Timer() { @Override public void run() { // detach assist popup popupElement.getParentNode().removeChild(popupElement); // remove all items from popup element listElement.setInnerHTML(""); } }.schedule(250); visible = false; selectedElement = null; showDocTimer.cancel(); removePopupEventListeners(); } @Override public void handleEvent(Event evt) { if (evt instanceof KeyboardEvent) { final KeyboardEvent keyEvent = (KeyboardEvent)evt; switch (keyEvent.getKeyCode()) { case KeyCodes.KEY_ESCAPE: Scheduler.get().scheduleDeferred(new Scheduler.ScheduledCommand() { @Override public void execute() { hide(); } }); break; case KeyCodes.KEY_DOWN: selectNext(); evt.preventDefault(); break; case KeyCodes.KEY_UP: selectPrevious(); evt.preventDefault(); break; case KeyCodes.KEY_HOME: selectElement(listElement.getFirstElementChild()); break; case KeyCodes.KEY_END: selectElement(listElement.getLastElementChild()); break; case KeyCodes.KEY_ENTER: evt.preventDefault(); evt.stopImmediatePropagation(); validateItem(true); break; case KeyCodes.KEY_TAB: evt.preventDefault(); evt.stopImmediatePropagation(); validateItem(false); break; } } } /** * Uses to determine the autocompletion popup visibility. * * @return <b>true</b> if the popup is visible, otherwise returns <b>false</b> */ public boolean isVisible() { return visible; } public void showCompletionInfo() { if (visible && selectedElement != null) { selectedElement.dispatchEvent(createValidateEvent(DOCUMENTATION)); } } }