/******************************************************************************* * Copyright (c) 2012-2017 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.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.gwt.user.client.rpc.AsyncCallback; 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 com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.AssistedInject; 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.HTMLCollection; import elemental.html.SpanElement; import elemental.html.Window; import org.eclipse.che.ide.api.editor.codeassist.Completion; import org.eclipse.che.ide.api.editor.codeassist.CompletionProposal; import org.eclipse.che.ide.api.editor.codeassist.CompletionProposalExtension; import org.eclipse.che.ide.api.editor.events.CompletionRequestEvent; import org.eclipse.che.ide.api.editor.text.LinearRange; import org.eclipse.che.ide.api.editor.texteditor.HandlesUndoRedo; import org.eclipse.che.ide.api.editor.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.ui.popup.PopupResources; import org.eclipse.che.ide.util.dom.Elements; import org.eclipse.che.ide.util.loging.Log; import java.util.List; import static elemental.css.CSSStyleDeclaration.Unit.PX; /** * @author Evgen Vidolob * @author Vitaliy Guliy * @author Kaloyan Raev */ public class ContentAssistWidget implements EventListener { /** * Custom event type. */ private static final String CUSTOM_EVT_TYPE_VALIDATE = "itemvalidate"; private static final String DOCUMENTATION = "documentation"; private static final int DOM_ITEMS_SIZE = 50; private final PopupResources popupResources; /** * The related editor. */ private final OrionEditorWidget textEditor; /** * 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 OrionKeyModeOverlay assistMode; private boolean visible = false; private boolean focused = false; private boolean insert = true; /** * The previously focused element. */ private Element selectedElement; private FlowPanel docPopup; private OrionTextViewOverlay.EventHandler<OrionModelChangedEventOverlay> handler; private List<CompletionProposal> proposals; private Timer callCodeAssistTimer = new Timer() { @Override public void run() { textEditor.getDocument().getDocumentHandle().getDocEventBus().fireEvent(new CompletionRequestEvent()); } }; private Timer showDocTimer = new Timer() { @Override public void run() { if (selectedElement != null) { selectedElement.dispatchEvent(createValidateEvent(DOCUMENTATION)); } } }; @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 = evt -> { if (!(evt instanceof MouseEvent)) { return; } final MouseEvent mouseEvent = (MouseEvent) evt; final EventTarget target = mouseEvent.getTarget(); if (target instanceof Element) { final Element elementTarget = (Element) target; if (docPopup.isVisible() && (elementTarget.equals(docPopup.getElement()) || elementTarget.getParentElement().equals(docPopup.getElement()))) { return; } if (!ContentAssistWidget.this.popupElement.contains(elementTarget)) { hide(); evt.preventDefault(); } } }; handler = event -> { callCodeAssistTimer.cancel(); callCodeAssistTimer.schedule(250); }; } public void validateItem(boolean insert) { this.insert = insert; selectedElement.dispatchEvent(createValidateEvent(CUSTOM_EVT_TYPE_VALIDATE)); } /** * @param eventType * @return */ private native CustomEvent createValidateEvent(String eventType) /*-{ return new CustomEvent(eventType); }-*/; /** * Creates a new proposal item. * * @param index of proposal */ private Element createProposalPopupItem(int index) { final CompletionProposal proposal = proposals.get(index); final Element element = Elements.createLiElement(popupResources.popupStyle().item()); element.setId(Integer.toString(index)); 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); final EventListener validateListener = evt -> applyProposal(proposal); element.addEventListener(Event.DBLCLICK, validateListener, false); element.addEventListener(CUSTOM_EVT_TYPE_VALIDATE, validateListener, false); element.addEventListener(Event.CLICK, event -> select(element), false); element.addEventListener(Event.FOCUS, this, false); element.addEventListener(DOCUMENTATION, new EventListener() { @Override public void handleEvent(Event event) { proposal.getAdditionalProposalInfo(new AsyncCallback<Widget>() { @Override public void onSuccess(Widget info) { if (info != null) { docPopup.clear(); docPopup.add(info); docPopup.getElement().getStyle().setOpacity(1); if (!docPopup.isAttached()) { final int x = popupElement.getOffsetLeft() + popupElement.getOffsetWidth() + 3; final int y = popupElement.getOffsetTop(); RootPanel.get().add(docPopup); updateMenuPosition(docPopup, x, y); } } else { docPopup.getElement().getStyle().setOpacity(0); } } @Override public void onFailure(Throwable e) { Log.error(getClass(), e); docPopup.getElement().getStyle().setOpacity(0); } }); } }, false); return element; } private void updateMenuPosition(FlowPanel popupMenu, int x, int y) { if (x + popupMenu.getOffsetWidth() > com.google.gwt.user.client.Window.getClientWidth()) { popupMenu.getElement().getStyle().setLeft(x - popupMenu.getOffsetWidth() - popupElement.getOffsetWidth() - 5, Style.Unit.PX); } else { popupMenu.getElement().getStyle().setLeft(x, Style.Unit.PX); } if (y + popupMenu.getOffsetHeight() > com.google.gwt.user.client.Window.getClientHeight()) { popupMenu.getElement().getStyle().setTop(y - popupMenu.getOffsetHeight() - popupElement.getOffsetHeight() - 3, Style.Unit.PX); } else { popupMenu.getElement().getStyle().setTop(y, Style.Unit.PX); } } 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", () -> { hide(); return true; }); textEditor.getTextView().setAction("cheContentAssistApply", () -> { validateItem(true); return true; }); textEditor.getTextView().setAction("cheContentAssistPreviousProposal", () -> { selectPrevious(); return true; }); textEditor.getTextView().setAction("cheContentAssistNextProposal", () -> { selectNext(); return true; }); textEditor.getTextView().setAction("cheContentAssistNextPage", () -> { selectNextPage(); return true; }); textEditor.getTextView().setAction("cheContentAssistPreviousPage", () -> { selectPreviousPage(); return true; }); textEditor.getTextView().setAction("cheContentAssistEnd", () -> { selectLast(); return true; }); textEditor.getTextView().setAction("cheContentAssistHome", () -> { selectFirst(); return true; }); textEditor.getTextView().setAction("cheContentAssistTab", () -> { validateItem(false); return true; }); textEditor.getTextView().addEventListener("ModelChanging", handler); listElement.addEventListener(Event.KEYDOWN, this, false); popupBodyElement.addEventListener(Event.SCROLL, 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 scroll listener popupBodyElement.removeEventListener(Event.SCROLL, this, false); // remove the mouse listener Elements.getDocument().removeEventListener(Event.MOUSEDOWN, this.popupListener); } private void selectFirst() { scrollTo(0); updateIfNecessary(); select(getFirstItemInDOM()); } private void selectLast() { scrollTo(getTotalItems() - 1); updateIfNecessary(); select(getLastItemInDOM()); } private void select(int index) { select(getItem(index)); } private void selectPrevious() { Element previousElement = selectedElement.getPreviousElementSibling(); if (previousElement != null && previousElement == getExtraTopRow() && getExtraTopRow().isHidden()) { selectLast(); } else { selectOffset(-1); } } private void selectPreviousPage() { int offset = getItemsPerPage() - 1; selectOffset(-offset); } private void selectNext() { Element nextElement = selectedElement.getNextElementSibling(); if (nextElement != null && nextElement == getExtraBottomRow() && getExtraBottomRow().isHidden()) { selectFirst(); } else { selectOffset(1); } } private void selectNextPage() { int offset = getItemsPerPage() - 1; selectOffset(offset); } private void selectOffset(int offset) { int index = getItemId(selectedElement) + offset; index = Math.max(index, 0); index = Math.min(index, getTotalItems() - 1); if (!isItemInDOM(index)) { scrollTo(index); update(); } select(index); } private void select(Element element) { if (element == selectedElement) { return; } if (selectedElement != null) { selectedElement.removeAttribute("selected"); } selectedElement = element; selectedElement.setAttribute("selected", "true"); showDocTimer.cancel(); showDocTimer.schedule(docPopup.isAttached() ? 100 : 1500); if (selectedElement.getOffsetTop() < this.popupBodyElement.getScrollTop()) { selectedElement.scrollIntoView(true); } else if ((selectedElement.getOffsetTop() + selectedElement.getOffsetHeight()) > (this.popupBodyElement.getScrollTop() + this.popupBodyElement.getClientHeight())) { selectedElement.scrollIntoView(false); } } /** * Displays assist popup relative to the current cursor position. * * @param proposals proposals to display */ public void show(final List<CompletionProposal> proposals) { this.proposals = 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 (getTotalItems() == 0) { final Element emptyElement = Elements.createLiElement(popupResources.popupStyle().item()); emptyElement.setTextContent("No proposals"); listElement.appendChild(emptyElement); return; } /* Automatically apply the completion proposal if it only one. */ if (getTotalItems() == 1) { applyProposal(proposals.get(0)); return; } /* 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(1); Elements.getDocument().getBody().appendChild(this.popupElement); /* Add the top extra row. */ setExtraRowHeight(appendExtraRow(), 0); /* Add the popup items. */ for (int i = 0; i < Math.min(DOM_ITEMS_SIZE, getTotalItems()); i++) { listElement.appendChild(createProposalPopupItem(i)); } /* Add the bottom extra row. */ setExtraRowHeight(appendExtraRow(), Math.max(0, getTotalItems() - DOM_ITEMS_SIZE)); /* 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 have their attached. */ if (!visible) { addPopupEventListeners(); } /* Indicates the codeassist is visible. */ visible = true; focused = false; /* Update documentation popup position */ docPopup.getElement().getStyle() .setLeft(popupElement.getOffsetLeft() + popupElement.getOffsetWidth() + 3, Style.Unit.PX); docPopup.getElement().getStyle() .setTop(popupElement.getOffsetTop(), Style.Unit.PX); /* Select first row. */ selectFirst(); } /** * Hides the popup and displaying javadoc. */ public void hide() { textEditor.setFocus(); 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 if (popupElement.getParentNode() != null) { 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 (Event.KEYDOWN.equalsIgnoreCase(evt.getType())) { final KeyboardEvent keyEvent = (KeyboardEvent) evt; switch (keyEvent.getKeyCode()) { case KeyCodes.KEY_ESCAPE: Scheduler.get().scheduleDeferred(this::hide); break; case KeyCodes.KEY_DOWN: selectNext(); evt.preventDefault(); break; case KeyCodes.KEY_UP: selectPrevious(); evt.preventDefault(); break; case KeyCodes.KEY_PAGEUP: selectPreviousPage(); evt.preventDefault(); break; case KeyCodes.KEY_PAGEDOWN: selectNextPage(); evt.preventDefault(); break; case KeyCodes.KEY_HOME: selectFirst(); break; case KeyCodes.KEY_END: selectLast(); break; case KeyCodes.KEY_ENTER: evt.preventDefault(); evt.stopImmediatePropagation(); validateItem(true); break; case KeyCodes.KEY_TAB: evt.preventDefault(); evt.stopImmediatePropagation(); validateItem(false); break; } } else if (Event.SCROLL.equalsIgnoreCase(evt.getType())) { updateIfNecessary(); } else if (Event.FOCUS.equalsIgnoreCase(evt.getType())) { focused = true; } } private void updateIfNecessary() { int scrollTop = popupBodyElement.getScrollTop(); int extraTopHeight = getExtraTopRow().getClientHeight(); if (scrollTop < extraTopHeight) { // the scroll bar is above the buffered area update(); } else if (scrollTop + popupBodyElement.getClientHeight() > extraTopHeight + getItemHeight() * DOM_ITEMS_SIZE) { // the scroll bar is below the buffered area update(); } } private void update() { int topVisibleItem = popupBodyElement.getScrollTop() / getItemHeight(); int topDOMItem = Math.max(0, topVisibleItem - (DOM_ITEMS_SIZE - getItemsPerPage()) / 2); int bottomDOMItem = Math.min(getTotalItems() - 1, topDOMItem + DOM_ITEMS_SIZE - 1); if (bottomDOMItem == getTotalItems() - 1) { topDOMItem = Math.max(0, bottomDOMItem - DOM_ITEMS_SIZE + 1); } // resize the extra top row setExtraRowHeight(getExtraTopRow(), topDOMItem); // replace the DOM items with new content based on the scroll position HTMLCollection nodes = listElement.getChildren(); for (int i = 0; i <= (bottomDOMItem - topDOMItem); i++) { Element newNode = createProposalPopupItem(topDOMItem + i); listElement.replaceChild(newNode, nodes.item(i + 1)); // check if the item is the selected if (newNode.getId().equals(selectedElement.getId())) { selectedElement = newNode; selectedElement.setAttribute("selected", "true"); } } // resize the extra bottom row setExtraRowHeight(getExtraBottomRow(), getTotalItems() - (bottomDOMItem + 1)); // ensure the keyboard focus is in the visible area if (focused) { getItem(topDOMItem + (bottomDOMItem - topDOMItem) / 2).focus(); } } /** * 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)); } } private void applyProposal(CompletionProposal proposal) { CompletionProposal.CompletionCallback callback = this::applyCompletion; hide(); if (proposal instanceof CompletionProposalExtension) { ((CompletionProposalExtension) proposal).getCompletion(insert, callback); } else { proposal.getCompletion(callback); } } private void applyCompletion(Completion completion) { textEditor.setFocus(); UndoableEditor undoableEditor = textEditor; HandlesUndoRedo undoRedo = undoableEditor.getUndoRedo(); try { if (undoRedo != null) { undoRedo.beginCompoundChange(); } completion.apply(textEditor.getDocument()); final LinearRange selection = completion.getSelection(textEditor.getDocument()); if (selection != null) { selectInEditor(selection); } } catch (final Exception e) { Log.error(getClass(), e); } finally { if (undoRedo != null) { undoRedo.endCompoundChange(); } } } private void selectInEditor(LinearRange selection) { int lineAtOffset = textEditor.getDocument().getLineAtOffset(selection.getStartOffset()); boolean scroll = false; if (lineAtOffset < textEditor.getTextView().getTopIndex() || lineAtOffset > textEditor.getTextView().getBottomIndex()) { scroll = true; } textEditor.getDocument().setSelectedRange(selection, scroll); } private Element getExtraTopRow() { return (listElement == null) ? null : listElement.getFirstElementChild(); } private Element getExtraBottomRow() { return (listElement == null) ? null : listElement.getLastElementChild(); } private Element getFirstItemInDOM() { Element extraTopRow = getExtraTopRow(); return (extraTopRow == null) ? null : extraTopRow.getNextElementSibling(); } private Element getLastItemInDOM() { Element extraBottomRow = getExtraBottomRow(); return (extraBottomRow == null) ? null : extraBottomRow.getPreviousElementSibling(); } private int getItemId(Element item) { return Integer.parseInt(item.getId()); } private Element getItem(int index) { return (Element) listElement.getChildren().namedItem(Integer.toString(index)); } private int getItemHeight() { Element item = getFirstItemInDOM(); return (item == null) ? 0 : item.getClientHeight(); } private Element appendExtraRow() { Element extraRow = Elements.createLiElement(); listElement.appendChild(extraRow); return extraRow; } private void setExtraRowHeight(Element extraRow, int items) { int height = items * getItemHeight(); extraRow.getStyle().setHeight(height + "px"); extraRow.setHidden(height <= 0); } private int getItemsPerPage() { return (int) Math.ceil((double) popupBodyElement.getClientHeight() / getItemHeight()); } private int getTotalItems() { return (proposals == null) ? 0 : proposals.size(); } private boolean isItemInDOM(int index) { return index >= getItemId(getFirstItemInDOM()) && index <= getItemId(getLastItemInDOM()); } private void scrollTo(int index) { int currentScrollTop = popupBodyElement.getScrollTop(); int newScrollTop = index * getItemHeight(); if (currentScrollTop < newScrollTop) { // the scrolling direction is from top to bottom, so show the item // at the bottom of the widget newScrollTop -= popupBodyElement.getClientHeight(); } popupBodyElement.setScrollTop(newScrollTop); } }