// 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 org.eclipse.che.ide.ui.list;
import elemental.events.Event;
import elemental.events.EventListener;
import elemental.dom.Element;
import elemental.js.dom.JsElement;
import org.eclipse.che.ide.mvp.UiComponent;
import org.eclipse.che.ide.ui.ElementView;
import org.eclipse.che.ide.util.CssUtils;
import org.eclipse.che.ide.util.dom.DomUtils;
import org.eclipse.che.ide.util.dom.Elements;
import org.eclipse.che.ide.util.loging.Log;
import com.google.gwt.dom.client.Node;
import com.google.gwt.resources.client.ClientBundle;
import com.google.gwt.resources.client.CssResource;
import com.google.gwt.user.client.ui.HTML;
import com.google.gwt.user.client.ui.IsWidget;
import com.google.gwt.user.client.ui.Widget;
import java.util.ArrayList;
import java.util.List;
/** A simple list widget for displaying flat collections of things. */
// TODO: When we hit a place where a component wants to ditch all of
// the default simple list styles, figure out a way to make that easy.
public class SimpleList<M> extends UiComponent<SimpleList.View> implements IsWidget {
/** Create using the default CSS. */
public static <M> SimpleList<M> create(View view, Resources res, ListItemRenderer<M> itemRenderer,
ListEventDelegate<M> eventDelegate) {
return new SimpleList<M>(view, view, view, res.defaultSimpleListCss(), itemRenderer, eventDelegate);
}
/** Create with custom CSS. */
public static <M> SimpleList<M> create(View view, Css css, ListItemRenderer<M> itemRenderer,
ListEventDelegate<M> eventDelegate) {
return new SimpleList<M>(view, view, view, css, itemRenderer, eventDelegate);
}
/**
* Create and configure control instance.
* <p/>
* <p> Use this method when either only part of control is scrollable
* ({@code view != container}) or elements are stored in table
* ({@code container != itemHolder}) or both.
*
* @param view
* element that receives control decorations (shadow, etc.)
* @param container
* element whose content is scrolled
* @param itemHolder
* element to add items to
*/
public static <M> SimpleList<M> create(View view, Element container, Element itemHolder, Css css,
ListItemRenderer<M> itemRenderer, ListEventDelegate<M> eventDelegate) {
return new SimpleList<M>(view, container, itemHolder, css, itemRenderer, eventDelegate);
}
/**
* Called each time we render an item in the list. Provides an opportunity for
* implementors/clients to customize the DOM structure of each list item.
*/
public abstract static class ListItemRenderer<M> {
public abstract void render(Element listItemBase, M itemData);
/**
* A factory method for the outermost element used by a list item.
* <p/>
* The default implementation returns a div element.
*/
public Element createElement() {
return Elements.createDivElement();
}
}
/** Receives events fired on items in the list. */
public interface ListEventDelegate<M> {
void onListItemClicked(Element listItemBase, M itemData);
void onListItemDoubleClicked(Element listItemBase, M itemData);
}
/** A {@link ListEventDelegate} which performs no action. */
public static class NoOpListEventDelegate<M> implements ListEventDelegate<M> {
@Override
public void onListItemClicked(Element listItemBase, M itemData) {
}
@Override
public void onListItemDoubleClicked(Element listItemBase, M itemData) {
}
}
/** Item style selectors for a simple list item. */
public interface Css extends CssResource {
int menuListBorderPx();
String listItem();
String listBase();
String listContainer();
}
public interface Resources extends ClientBundle {
@Source({"SimpleList.css", "org/eclipse/che/ide/ui/constants.css", "org/eclipse/che/ide/api/ui/style.css"})
Css defaultSimpleListCss();
}
/** Overlay type representing the base element of the SimpleList. */
public static class View extends ElementView<Void> {
protected View() {
}
}
/**
* A javascript overlay object which ties a list item's DOM element to its
* associated data.
*/
final static class ListItem<M> extends JsElement {
/**
* Creates a new ListItem overlay object by creating a div element,
* assigning it the listItem css class, and associating it to its data.
*/
public static <M> ListItem<M> create(ListItemRenderer<M> factory, Css css, M data) {
Element element = factory.createElement();
Elements.addClassName(css.listItem(), element);
ListItem<M> item = ListItem.cast(element);
item.setData(data);
return item;
}
/**
* Casts an element to its ListItem representation. This is an unchecked
* cast so we extract it into this static factory method so we don't have to
* suppress warnings all over the place.
*/
@SuppressWarnings("unchecked")
public static <M> ListItem<M> cast(Element element) {
return (ListItem<M>)element;
}
protected ListItem() {
// Unused constructor
}
public final native M getData() /*-{
return this.__data;
}-*/;
public final native void setData(M data) /*-{
this.__data = data;
}-*/;
/**
* Reuses this elements container by clearing it's contents and updating its
* data. This allows it to be sent back to the renderer.
*
* @param className
* The css class to set to the container. All other css
* classes are cleared.
*/
public final native void reuseContainerElement(M data, String className) /*-{
this.className = className;
this.innerHTML = "";
this.__data = data;
}-*/;
}
/**
* A model which maintains the internal state of the list including selection
* and DOM elements.
*/
public class Model<M> implements HasSelection<M> {
private static final int NO_SELECTION = -1;
/** Defines the attribute used to indicate selection. */
private static final String SELECTED_ATTRIBUTE = "SELECTED";
private final ListEventDelegate<M> delegate;
private final List<ListItem<M>> listItems = new ArrayList<>();
private int selectedIndex;
/**
* Creates a new model for use by SimpleList. The provided delegate should
* not be null.
*/
public Model(ListEventDelegate<M> delegate) {
this.delegate = delegate;
// set the initially selected item
selectedIndex = 0;
}
@Override
public int getSelectedIndex() {
return selectedIndex;
}
@Override
public M getSelectedItem() {
ListItem<M> selectedListItem = getSelectedListItem();
return selectedListItem != null ? selectedListItem.getData() : null;
}
/** Returns the currently selected list element or null. */
private ListItem<M> getSelectedListItem() {
if (selectedIndex >= 0 && selectedIndex < listItems.size()) {
return listItems.get(selectedIndex);
}
return null;
}
@Override
public boolean selectNext() {
return setSelectedItem(selectedIndex + 1);
}
@Override
public boolean selectPrevious() {
return setSelectedItem(selectedIndex - 1);
}
@Override
public boolean selectNextPage() {
return setSelectedItem(Math.min(selectedIndex + getPageSize(), size() - 1));
}
@Override
public boolean selectPreviousPage() {
return setSelectedItem(Math.max(0, selectedIndex - getPageSize()));
}
private int getPageSize() {
int indexAboveViewport = findIndexOfFirstNotInViewport(selectedIndex, false);
int indexBelowViewport = findIndexOfFirstNotInViewport(selectedIndex, true);
// A minimum size of 1
return Math.max(1, indexBelowViewport - indexAboveViewport - 1);
}
/**
* Returns the index of the first item that is not fully in the viewport. If
* all items are, it will return the last item in the given direction.
*/
private int findIndexOfFirstNotInViewport(int beginIndex, boolean forward) {
final int deltaIndex = forward ? 1 : -1;
int i = beginIndex;
for (; i >= 0 && i < size(); i += deltaIndex) {
if (!DomUtils.isFullyInScrollViewport(container, listItems.get(i))) {
return i;
}
}
return i + -deltaIndex;
}
@Override
public void handleClick() {
ListItem<M> item = getSelectedListItem();
if (item != null) {
delegate.onListItemClicked(item, item.getData());
}
}
@Override
public void clearSelection() {
maybeRemoveSelectionFromElement();
selectedIndex = NO_SELECTION;
}
@Override
public int size() {
return listItems.size();
}
@Override
public boolean setSelectedItem(int index) {
if (index >= 0 && index < listItems.size()) {
maybeRemoveSelectionFromElement();
selectedIndex = index;
getSelectedListItem().setAttribute(SELECTED_ATTRIBUTE, SELECTED_ATTRIBUTE);
ensureSelectedIsVisible();
return true;
}
return false;
}
@Override
public boolean setSelectedItem(M item) {
int index = -1;
for (int i = 0; i < listItems.size(); i++) {
if (listItems.get(i).getData().equals(item)) {
index = i;
break;
}
}
return setSelectedItem(index);
}
private void ensureSelectedIsVisible() {
DomUtils.ensureScrolledTo(container, model.getSelectedListItem());
}
/** Removes selection from the currently selected element if it exists. */
private void maybeRemoveSelectionFromElement() {
ListItem<M> element = getSelectedListItem();
if (element != null) {
element.removeAttribute(SELECTED_ATTRIBUTE);
}
}
}
private final ListEventDelegate<M> eventDelegate;
private final ListItemRenderer<M> itemRenderer;
private final Css css;
private final Model<M> model;
private final Element container;
private final Element itemHolder;
private HTML widget;
private SimpleList(View view, Element container, Element itemHolder, Css css, ListItemRenderer<M> itemRenderer,
ListEventDelegate<M> eventDelegate) {
super(view);
this.css = css;
this.model = new Model<M>(eventDelegate);
this.itemRenderer = itemRenderer;
this.eventDelegate = eventDelegate;
this.itemHolder = itemHolder;
this.container = container;
Elements.addClassName(css.listBase(), view);
Elements.addClassName(css.listContainer(), container);
attachEventHandlers();
}
/** Returns the current number of items in the simple list. */
public int size() {
return model.listItems.size();
}
public void render(List<M> items) {
M selectedItem = model.getSelectedItem();
model.clearSelection();
itemHolder.setInnerHTML("");
model.listItems.clear();
for (int i = 0; i < items.size(); i++) {
ListItem<M> elem = ListItem.create(itemRenderer, css, items.get(i));
CssUtils.setUserSelect(elem, false);
model.listItems.add(elem);
itemRenderer.render(elem, elem.getData());
itemHolder.appendChild(elem);
}
model.setSelectedItem(selectedItem);
}
public HasSelection<M> getSelectionModel() {
return model;
}
/** @return true if the list or any one of its element's currently have focus. */
public boolean hasFocus() {
return DomUtils.isElementOrChildFocused(container);
}
private void attachEventHandlers() {
getView().addEventListener(Event.CLICK, new EventListener() {
@Override
public void handleEvent(Event evt) {
Element listItemElem = CssUtils.getAncestorOrSelfWithClassName((Element)evt.getTarget(), css.listItem());
if (listItemElem == null) {
Log.warn(SimpleList.class, "Unable to find an ancestor that was a list item for a click on: ",
evt.getTarget());
return;
}
ListItem<M> listItem = ListItem.cast(listItemElem);
eventDelegate.onListItemClicked(listItem, listItem.getData());
}
}, false);
getView().addEventListener(Event.DBLCLICK, new EventListener() {
@Override
public void handleEvent(Event evt) {
Element listItemElem = CssUtils.getAncestorOrSelfWithClassName((Element)evt.getTarget(), css.listItem());
if (listItemElem == null) {
Log.warn(SimpleList.class, "Unable to find an ancestor that was a list item for a click on: ",
evt.getTarget());
return;
}
ListItem<M> listItem = ListItem.cast(listItemElem);
eventDelegate.onListItemDoubleClicked(listItem, listItem.getData());
}
}, false);
}
public M get(int i) {
return model.listItems.get(i).getData();
}
/** {@inheritDoc} */
@Override
public Widget asWidget() {
if (widget == null) {
widget = new HTML();
widget.getElement().appendChild((Node)getView().getElement());
}
return widget;
}
}