// Copyright 2012 Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.collide.client.code.autocomplete.integration; import com.google.collide.client.code.autocomplete.AutocompleteBox; import com.google.collide.client.code.autocomplete.AutocompleteProposal; import com.google.collide.client.code.autocomplete.AutocompleteProposals; import com.google.collide.client.code.autocomplete.SignalEventEssence; import com.google.collide.client.editor.Editor; import com.google.collide.client.editor.FocusManager; import com.google.collide.client.ui.list.SimpleList; import com.google.collide.client.ui.list.SimpleList.View; import com.google.collide.client.ui.menu.AutoHideController; import com.google.collide.client.util.CssUtils; import com.google.collide.client.util.Elements; import com.google.collide.client.util.dom.DomUtils; import com.google.collide.json.shared.JsonArray; import com.google.collide.shared.document.anchor.ReadOnlyAnchor; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.gwt.event.dom.client.KeyCodes; import com.google.gwt.resources.client.CssResource; import org.waveprotocol.wave.client.common.util.SignalEvent; import elemental.css.CSSStyleDeclaration; import elemental.html.ClientRect; import elemental.html.Element; import elemental.html.TableCellElement; import elemental.html.TableElement; /** * A controller for managing the UI for showing autocomplete proposals. * */ public class AutocompleteUiController implements AutocompleteBox { public interface Resources extends SimpleList.Resources { @Source("AutocompleteComponent.css") Css autocompleteComponentCss(); } public interface Css extends CssResource { String cappedProposalLabel(); String proposalLabel(); String proposalGroup(); String container(); String items(); String hint(); int maxHeight(); } private static final int MAX_COMPLETIONS_TO_SHOW = 100; private static final AutocompleteProposal CAPPED_INDICATOR = new AutocompleteProposal(""); private final SimpleList.ListItemRenderer<AutocompleteProposal> listItemRenderer = new SimpleList.ListItemRenderer<AutocompleteProposal>() { @Override public void render(Element itemElement, AutocompleteProposal itemData) { TableCellElement label = Elements.createTDElement(css.proposalLabel()); TableCellElement group = Elements.createTDElement(css.proposalGroup()); if (itemData != CAPPED_INDICATOR) { label.setTextContent(itemData.getLabel()); group.setTextContent(itemData.getPath().getPathString()); } else { label.setTextContent("Type for more results"); label.addClassName(css.cappedProposalLabel()); } itemElement.appendChild(label); itemElement.appendChild(group); } @Override public Element createElement() { return Elements.createTRElement(); } }; private final SimpleList.ListEventDelegate<AutocompleteProposal> listDelegate = new SimpleList.ListEventDelegate<AutocompleteProposal>() { @Override public void onListItemClicked(Element itemElement, AutocompleteProposal itemData) { Preconditions.checkNotNull(delegate); if (itemData == CAPPED_INDICATOR) { return; } delegate.onSelect(autocompleteProposals.select(itemData)); } }; private final AutoHideController autoHideController; private final Css css; private final SimpleList<AutocompleteProposal> list; private Events delegate; private final Editor editor; private final Element box; private final Element container; private final Element hint; /** Will be non-null when the popup is showing */ private ReadOnlyAnchor anchor; /** * True to force the layout above the anchor, false to layout below. This * should be set when showing from the hidden state. It's used to keep * the position consistent while the box is visible. */ private boolean positionAbove; /** * The currently displayed proposals. This may contain more proposals than actually shown since we * cap the maximum number of visible proposals. This will be null if the UI is not showing. */ private AutocompleteProposals autocompleteProposals; public AutocompleteUiController(Editor editor, Resources res) { this.editor = editor; this.css = res.autocompleteComponentCss(); box = Elements.createDivElement(); // Prevent our mouse events from going to the editor DomUtils.stopMousePropagation(box); TableElement tableElement = Elements.createTableElement(); tableElement.setClassName(css.items()); container = Elements.createDivElement(css.container()); DomUtils.preventExcessiveScrollingPropagation(container); container.appendChild(tableElement); box.appendChild(container); hint = Elements.createDivElement(css.hint()); CssUtils.setDisplayVisibility2(hint, false); box.appendChild(hint); list = SimpleList.create((View) box, container, tableElement, res.defaultSimpleListCss(), listItemRenderer, listDelegate); autoHideController = AutoHideController.create(box); autoHideController.setCaptureOutsideClickOnClose(false); autoHideController.setDelay(-1); } @Override public boolean isShowing() { return autoHideController.isShowing(); } @Override public boolean consumeKeySignal(SignalEventEssence signal) { Preconditions.checkState(isShowing()); Preconditions.checkNotNull(delegate); if ((signal.keyCode == KeyCodes.KEY_TAB) || (signal.keyCode == KeyCodes.KEY_ENTER)) { delegate.onSelect(autocompleteProposals.select(list.getSelectionModel().getSelectedItem())); return true; } if (signal.keyCode == KeyCodes.KEY_ESCAPE) { delegate.onCancel(); return true; } if (signal.type != SignalEvent.KeySignalType.NAVIGATION) { return false; } if ((signal.keyCode == KeyCodes.KEY_DOWN)) { list.getSelectionModel().selectNext(); return true; } if (signal.keyCode == KeyCodes.KEY_UP) { list.getSelectionModel().selectPrevious(); return true; } if ((signal.keyCode == KeyCodes.KEY_LEFT) || (signal.keyCode == KeyCodes.KEY_RIGHT)) { delegate.onCancel(); return true; } if (signal.keyCode == KeyCodes.KEY_PAGEUP) { list.getSelectionModel().selectPreviousPage(); return true; } if (signal.keyCode == KeyCodes.KEY_PAGEDOWN) { list.getSelectionModel().selectNextPage(); return true; } return false; } @Override public void setDelegate(Events delegate) { this.delegate = delegate; } @Override public void dismiss() { boolean hadFocus = list.hasFocus(); autoHideController.hide(); if (anchor != null) { editor.getBuffer().removeAnchoredElement(anchor, autoHideController.getView().getElement()); anchor = null; } autocompleteProposals = null; FocusManager focusManager = editor.getFocusManager(); if (hadFocus && !focusManager.hasFocus()) { focusManager.focus(); } } @Override public void positionAndShow(AutocompleteProposals items) { this.autocompleteProposals = items; this.anchor = editor.getSelection().getCursorAnchor(); boolean showingFromHidden = !autoHideController.isShowing(); if (showingFromHidden) { list.getSelectionModel().clearSelection(); } final JsonArray<AutocompleteProposal> itemsToDisplay; if (items.size() <= MAX_COMPLETIONS_TO_SHOW) { itemsToDisplay = items.getItems(); } else { itemsToDisplay = items.getItems().slice(0, MAX_COMPLETIONS_TO_SHOW); itemsToDisplay.add(CAPPED_INDICATOR); } list.render(itemsToDisplay); if (list.getSelectionModel().getSelectedItem() == null) { list.getSelectionModel().setSelectedItem(0); } String hintText = items.getHint(); if (hintText == null) { hint.setTextContent(""); CssUtils.setDisplayVisibility2(hint, false); } else { hint.setTextContent(hintText); CssUtils.setDisplayVisibility2(hint, true); } autoHideController.show(); editor.getBuffer().addAnchoredElement(anchor, box); ensureRootElementWillBeOnScreen(showingFromHidden); } private void ensureRootElementWillBeOnScreen(boolean showingFromHidden) { // Remove any max-heights so we can get its desired height container.getStyle().removeProperty("max-height"); ClientRect bounds = box.getBoundingClientRect(); int height = (int) bounds.getHeight(); int delta = height - (int) container.getBoundingClientRect().getHeight(); ClientRect bufferBounds = editor.getBuffer().getBoundingClientRect(); int lineHeight = editor.getBuffer().getEditorLineHeight(); int lineTop = (int) bounds.getTop() - CssUtils.parsePixels(box.getStyle().getMarginTop()); int spaceAbove = lineTop - (int) bufferBounds.getTop(); int spaceBelow = (int) bufferBounds.getBottom() - lineTop - lineHeight; if (showingFromHidden) { // If it was already showing, we don't adjust the positioning. positionAbove = spaceAbove >= css.maxHeight() && spaceBelow < css.maxHeight(); } // Get available height. int maxHeight = positionAbove ? spaceAbove : spaceBelow; // Restrict to specified height. maxHeight = Math.min(maxHeight, css.maxHeight()); // Fit to content size. maxHeight = Math.min(maxHeight, height); container.getStyle().setProperty( "max-height", (maxHeight - delta) + CSSStyleDeclaration.Unit.PX); int marginTop = positionAbove ? -maxHeight : lineHeight; box.getStyle().setMarginTop(marginTop, CSSStyleDeclaration.Unit.PX); } @VisibleForTesting SimpleList<AutocompleteProposal> getList() { return list; } }