/*
* Copyright 2008-2013 Sergey Skladchikov
*
* 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.geogebra.web.web.gui.advanced.client.ui.widget;
import org.geogebra.web.web.gui.advanced.client.datamodel.ListDataModel;
import org.geogebra.web.web.gui.advanced.client.ui.AdvancedWidget;
import org.geogebra.web.web.gui.advanced.client.ui.widget.combo.ComboBoxChangeEvent;
import org.geogebra.web.web.gui.advanced.client.ui.widget.combo.DropDownPosition;
import org.geogebra.web.web.gui.advanced.client.ui.widget.combo.ListItemFactory;
import com.google.gwt.dom.client.Element;
import com.google.gwt.event.dom.client.ChangeEvent;
import com.google.gwt.event.dom.client.ChangeHandler;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.event.dom.client.FocusEvent;
import com.google.gwt.event.dom.client.HasChangeHandlers;
import com.google.gwt.event.dom.client.MouseOutEvent;
import com.google.gwt.event.dom.client.MouseOutHandler;
import com.google.gwt.event.dom.client.MouseOverEvent;
import com.google.gwt.event.dom.client.MouseOverHandler;
import com.google.gwt.event.dom.client.ScrollEvent;
import com.google.gwt.event.dom.client.ScrollHandler;
import com.google.gwt.event.logical.shared.ResizeEvent;
import com.google.gwt.event.logical.shared.ResizeHandler;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.user.client.Event;
import com.google.gwt.user.client.Window;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.FocusPanel;
import com.google.gwt.user.client.ui.PopupPanel;
import com.google.gwt.user.client.ui.ScrollPanel;
import com.google.gwt.user.client.ui.Widget;
/**
* This widget displays a scrollable list of items.
* <p/>
* Don't try to use it directly. It's just for the combo box widget.
*
* @author <a href="mailto:sskladchikov@gmail.com">Sergey Skladchikov</a>
* @param <T>
* model type
* @since 1.2.0
*/
public class ListPopupPanel<T extends ListDataModel> extends PopupPanel
implements AdvancedWidget, HasChangeHandlers {
/** a list of items */
private FlowPanel list;
/** items scrolling widget */
private ScrollPanel scrollPanel;
/** a flag meaning whether this widget is hidden */
private boolean hidden = true;
/** a parent selection box */
private ComboBox<T> comboBox;
/** item click handler */
private ClickHandler itemClickHandler;
/** mouse event handler */
private ListMouseHandler mouseEventsListener;
/** list of displayed items scroll handler */
private ScrollHandler listScrollHandler;
/** the row that is currently highlight in the list but my be not selected in the model */
private int highlightRow = -1;
/** number of visible rows in the scrollable area of the popup list. Limited by 30% of screen height by default */
private int visibleRows = -1;
/** the top item index to be displayed in the visible area of the list */
private int startItemIndex = 0;
/** enables or disables lazy rendering of the items list */
private boolean lazyRenderingEnabled;
/** registration of the {@link org.gwt.advanced.client.ui.widget.ListPopupPanel.ClickSpyHandler} */
private HandlerRegistration clickSpyRegistration;
/** drop down list position */
private DropDownPosition dropDownPosition = DropDownPosition.AUTO;
/**
* Creates an instance of this class and sets the parent combo box value.
*
* @param selectionTextBox is a selection box value.
*/
protected ListPopupPanel(ComboBox<T> selectionTextBox) {
super(false, false);
this.comboBox = selectionTextBox;
setStyleName("advanced-ListPopupPanel");
setWidget(getScrollPanel());
getList().setStyleName("list");
Window.addResizeHandler(new ListWindowResizeHandler());
}
/**
* This method adds a handler that will be invoked on choice.
*
* @param handler is a handler to be added.
*/
@Override
public HandlerRegistration addChangeHandler(ChangeHandler handler) {
return addHandler(handler, ChangeEvent.getType());
}
/**
* Getter for property 'hidden'.
*
* @return Value for property 'hidden'.
*/
public boolean isHidden() {
return hidden;
}
/**
* Gets a currently highlight row.<p/>
* Note that it may not be equal to the selected row index in the model.
*
* @return a row number that is currently highlight.
*/
public int getHighlightRow() {
return highlightRow;
}
/**
* This method gets an actual number of items displayed in the drop down.
*
* @return an item count.
*/
public int getItemCount() {
return getList().getWidgetCount();
}
/**
* Gets an item by its index<p/>
* If index < 0 or index >= {@link #getItemCount()} it throws an exception.
*
* @param index is an index of the item to get.
* @return a found item.
* @throws IndexOutOfBoundsException if index is invalid.
*/
public Widget getItem(int index) {
return getList().getWidget(index);
}
/**
* Gets an item index if it's displayed in the drop down list.<p/>
* Otherwise returns <code>-1</code>.
*
* @param item an item that is required to return.
* @return an item index value or <code>-1</code>.
*/
public int getItemIndex(Widget item) {
return getList().getWidgetIndex(item);
}
/**
* Sets the highlight row number.
*
* @param row is a row number to become highlight.
*/
protected void setHighlightRow(int row) {
if (row < getList().getWidgetCount()) {
Widget widget = null;
if (this.highlightRow >= 0 && getList().getWidgetCount() > this.highlightRow) {
widget = getList().getWidget(this.highlightRow);
}
if (widget != null) {
widget.removeStyleName("selected-row");
}
this.highlightRow = row;
if (row >= 0) {
widget = getList().getWidget(this.highlightRow);
widget.addStyleName("selected-row");
}
}
}
/**
* Checks whether the specified item is visible in the scroll area.<p/>
* The result is <code>true</code> if whole item is visible.
*
* @param index is an index of the item.
* @return a result of check.
*/
public boolean isItemVisible(int index) {
Widget item = getList().getWidget(index);
int itemTop = item.getAbsoluteTop();
int top = getScrollPanel().getAbsoluteTop();
return itemTop >= top && itemTop + item.getOffsetHeight() <= top + getScrollPanel().getOffsetHeight();
}
/**
* Makes the item visible in the list according to the check done by the {@link #isItemVisible(int)} method.
*
* @param item is an item to check.
*/
public void ensureVisible(Widget item) {
if (!isItemVisible(getList().getWidgetIndex(item))) {
getScrollPanel().ensureVisible(item);
}
}
/** {@inheritDoc} */
@Override
public void hide() {
if (clickSpyRegistration != null) {
clickSpyRegistration.removeHandler();
clickSpyRegistration = null;
}
super.hide();
setHidden(true);
}
/** {@inheritDoc} */
@Override
public void show() {
clickSpyRegistration = Event.addNativePreviewHandler(new ClickSpyHandler());
setHidden(false);
super.show();
adjustSize();
setHighlightRow(getComboBox().getModel().getSelectedIndex());
getComboBox().getDelegateHandler().onFocus(new FocusEvent() {
// nothing to do
});
}
/**
* Gets a number of visible rows.<p/>
* Values <= 0 interpreted as undefined.
*
* @return a visible rows to be displayed without scrolling.
*/
public int getVisibleRows() {
return visibleRows;
}
/**
* Sets visible rows number.<p/>
* You can pass a value <= 0. It will mean that this parameter in undefined.
*
* @param visibleRows is a number of rows to be displayed without scrolling.
*/
public void setVisibleRows(int visibleRows) {
this.visibleRows = visibleRows;
if (isShowing()) {
adjustSize();
}
}
/**
* Sets an item index that must be displayed on top.<p/>
* If the item is outside the currently visible area the list will be scrolled down
* to this item.
*
* @param index is an index of the element to display.
*/
public void setStartItemIndex(int index) {
this.startItemIndex = index < 0 ? 0 : index;
if (isShowing()) {
adjustSize();
}
}
/**
* @return start index
*/
public int getStartItemIndex() {
return startItemIndex;
}
/**
* Gets applied position of the drop down list.
*
* @return a drop down list position value.
*/
public DropDownPosition getDropDownPosition() {
return dropDownPosition;
}
/**
* Sets applied position of the drop down list.<p/>
* Being set the drop down is immediately applied if the list is opened.
*
* @param dropDownPosition is a drop down list position value.
*/
public void setDropDownPosition(DropDownPosition dropDownPosition) {
this.dropDownPosition = dropDownPosition;
if (isShowing()) {
adjustSize();
}
}
/** Adjusts drop down list sizes to make it take optimal area on the screen. */
protected void adjustSize() {
ScrollPanel table = getScrollPanel();
int rowsVisible = getVisibleRows();
int delta = getElement().getOffsetWidth() - getElement().getClientWidth();
getScrollPanel().setWidth((getComboBox().getOffsetWidth() - delta) + "px");
if (rowsVisible <= 0) {
table.setHeight("");
int spaceAbove = getComboBox().getAbsoluteTop();
int spaceUnder = Window.getClientHeight() - getComboBox().getAbsoluteTop() - getComboBox().getOffsetHeight();
setStyleAttribute(table.getElement(), "maxHeight",
Math.min(Window.getClientHeight() * 0.3, Math.max(spaceAbove, spaceUnder)) + "px");
} else if (getComboBox().getModel().getCount() > rowsVisible) {
int index = getStartItemIndex();
int count = getItemCount();
if (index + rowsVisible > count) {
index = count - rowsVisible + 1;
if (index < 0) {
index = 0;
}
}
int listHeight = 0;
int scrollPosition = 0;
for (int i = 0; i < index + rowsVisible && i < count; i++) {
int height = getList().getWidget(i).getOffsetHeight();
if (i < index) {
scrollPosition += height;
} else {
listHeight += height;
}
}
table.setSize(table.getOffsetWidth() + "px", listHeight + "px");
table.setVerticalScrollPosition(scrollPosition);
setStyleAttribute(table.getElement(), "maxHeight", "");
} else {
table.setHeight("");
setStyleAttribute(table.getElement(), "maxHeight", "");
}
resetPosition();
}
/** Chooses and sets a mostly appropriate position of the drop down list */
protected void resetPosition() {
if (getDropDownPosition() == DropDownPosition.ABOVE
|| getDropDownPosition() == DropDownPosition.AUTO
&& Window.getClientHeight() - getComboBox().getAbsoluteTop() - getComboBox().getOffsetHeight()
< getComboBox().getAbsoluteTop()
) {
setPopupPosition(getComboBox().getAbsoluteLeft(), getComboBox().getAbsoluteTop() - getOffsetHeight());
} else if (getDropDownPosition() == DropDownPosition.UNDER || getDropDownPosition() == DropDownPosition.AUTO) {
setPopupPosition(getComboBox().getAbsoluteLeft(),
getComboBox().getAbsoluteTop() + getComboBox().getOffsetHeight());
}
}
/**
* Checks whether the lazy rendering option is enabled.
*
* @return a result of check.
*/
protected boolean isLazyRenderingEnabled() {
return lazyRenderingEnabled;
}
/**
* Enables or disables lazy rendering option.<p/>
* If this option is enabled the list displays only several items on lazily reders other ones on scroll down.<p/>
* By default lazy rendering is disabled. Switch it on for really large (over 500 items) lists only.
*
* @param lazyRenderingEnabled is an option value.
*/
protected void setLazyRenderingEnabled(boolean lazyRenderingEnabled) {
this.lazyRenderingEnabled = lazyRenderingEnabled;
}
/** This method prepares the list of items for displaying. */
protected void prepareList() {
FlowPanel panel = getList();
if (!isLazyRenderingEnabled() || getComboBox().getModel().getCount() != getItemCount()) {
panel.clear();
}
fillList();
int selected = getComboBox().getModel().getSelectedIndex();
selectRow(selected);
if (selected >= 0 && selected < getItemCount()) {
ensureVisible(getItem(selected));
}
}
/**
* Fills the list of items starting from the current position and ending with rendering limits<p/>
* See {2link #isRenderingLimitReached()} for additional details since it's used in the body of this method.
*/
protected void fillList() {
FlowPanel panel = getList();
ListDataModel model = getComboBox().getModel();
ListItemFactory itemFactory = getComboBox().getListItemFactory();
int count = getItemCount();
int previouslyLoadedRows = count;
while (!isRenderingLimitReached(previouslyLoadedRows)) {
panel.add(adoptItemWidget(itemFactory.createWidget(model.get(count++))));
}
}
/**
* This method checks whether the limit of displayed items reached.<p/>
* It takes into account different aspects including setting of the widget, geometrical size of the
* drop down list and selected value that must be displayed.<p/>
* This method optimally chooses a number of items to display.
*
* @param previouslyRenderedRows is a number of rows previously loaded in the list
* (items count before filling the list).
* @return a result of check.
*/
protected boolean isRenderingLimitReached(int previouslyRenderedRows) {
ListDataModel model = getComboBox().getModel();
int previousHeight = 0;
if (previouslyRenderedRows > 0) {
Widget last = getItem(previouslyRenderedRows - 1);
Widget first = getItem(0);
previousHeight = last.getOffsetHeight() + last.getAbsoluteTop() - first.getAbsoluteTop();
}
return model.getCount() <= 0 // no data
// OR a selected value has already been displayed
|| getItemCount() > getComboBox().getSelectedIndex()
// AND one of the following conditions is true:
&& (getItemCount() >= model.getCount() //no items any more
// OR no limit but there are too many items
|| isLazyRenderingEnabled() && getVisibleRows() <= 0
&& getList().getOffsetHeight() - previousHeight >= Window.getClientHeight() * 0.6
// OR visible rows number is limited and there was a new page rendered excepting the first page
// since two pages may be displayed if the list is rendered first time
|| isLazyRenderingEnabled() && getVisibleRows() > 0 && getItemCount() - previouslyRenderedRows > 0
&& (getItemCount() - previouslyRenderedRows) % getVisibleRows() == 0
&& (getItemCount() - previouslyRenderedRows) / getVisibleRows() != 1);
}
/**
* This method higlights a selected row.
*
* @param newRow a row for selection.
*/
protected void selectRow(int newRow) {
ListDataModel model = getComboBox().getModel();
model.setSelectedIndex(newRow);
}
/**
* This method wraps the specified widget into the focus panel and adds necessary listeners.
*
* @param widget is an item widget to be wraped.
* @return a focus panel adopted for displaying.
*/
protected FocusPanel adoptItemWidget(Widget widget) {
FocusPanel panel = new FocusPanel(widget);
panel.addClickHandler(getItemClickHandler());
panel.addMouseOverHandler(getMouseEventsHandler());
panel.addMouseOutHandler(getMouseEventsHandler());
panel.setStyleName("item");
panel.getElement().getStyle().clearProperty("tabindex");
return panel;
}
/**
* Setter for property 'hidden'.
*
* @param hidden Value to set for property 'hidden'.
*/
protected void setHidden(boolean hidden) {
this.hidden = hidden;
}
/**
* Getter for property 'comboBox'.
*
* @return Value for property 'comboBox'.
*/
protected ComboBox<T> getComboBox() {
return comboBox;
}
/**
* Getter for property 'list'.
*
* @return Value for property 'list'.
*/
protected FlowPanel getList() {
if (list == null) {
list = new FlowPanel();
}
return list;
}
/**
* Getter for property 'scrollPanel'.
*
* @return Value for property 'scrollPanel'.
*/
public ScrollPanel getScrollPanel() {
if (scrollPanel == null) {
scrollPanel = new ScrollPanel();
scrollPanel.setAlwaysShowScrollBars(false);
scrollPanel.setWidget(getList());
setStyleAttribute(scrollPanel.getElement(), "overflowX", "hidden");
scrollPanel.addScrollHandler(getListScrollHandler());
}
return scrollPanel;
}
private static void setStyleAttribute(Element elem, String attr,
String value) {
elem.getStyle().setProperty(attr, value);
}
/**
* Getter for property 'itemClickHandler'.
*
* @return Value for property 'itemClickHandler'.
*/
protected ClickHandler getItemClickHandler() {
if (itemClickHandler == null) {
itemClickHandler = new ItemClickHandler();
}
return itemClickHandler;
}
/**
* Getter for property 'mouseEventsListener'.
*
* @return Value for property 'mouseEventsListener'.
*/
protected ListMouseHandler getMouseEventsHandler() {
if (mouseEventsListener == null) {
mouseEventsListener = new ListMouseHandler();
}
return mouseEventsListener;
}
/**
* Getter for property 'listScrollHandler'.
*
* @return Value for property 'listScrollHandler'.
*/
public ScrollHandler getListScrollHandler() {
if (listScrollHandler == null) {
listScrollHandler = new ListScrollHandler();
}
return listScrollHandler;
}
/** This is a click handler required to dispatch click events. */
protected class ItemClickHandler implements ClickHandler {
/** {@inheritDoc} */
@Override
public void onClick(ClickEvent event) {
int row = getList().getWidgetIndex((Widget) event.getSource());
selectRow(row);
ChangeEvent changeEvent = new ComboBoxChangeEvent(
row, ComboBoxChangeEvent.ChangeEventInputDevice.MOUSE
);
fireEvent(changeEvent);
}
}
/** This listener is required to handle mouse moving events over the list. */
protected class ListMouseHandler implements MouseOverHandler, MouseOutHandler {
/** {@inheritDoc} */
@Override
public void onMouseOut(MouseOutEvent event) {
if (getComboBox().isKeyPressed()) {
return;
}
((Widget) event.getSource()).removeStyleName("selected-row");
}
/** {@inheritDoc} */
@Override
public void onMouseOver(MouseOverEvent event) {
if (getComboBox().isKeyPressed()) {
return;
}
int index = getComboBox().getModel().getSelectedIndex();
if (index >= 0) {
getList().getWidget(index).removeStyleName("selected-row");
}
Widget sender = (Widget) event.getSource();
sender.addStyleName("selected-row");
setHighlightRow(getList().getWidgetIndex(sender));
}
}
/**
* This scroll handler is invoked on any scrolling event caotured by the items list.<p/>
* It check whether the scrolling position value is equal to the last item position and tries to
* render the next page of data.
*/
protected class ListScrollHandler implements ScrollHandler {
/** the list has been scrolled programatically */
private boolean autoScrollingEnabled;
/** see class docs */
@Override
public void onScroll(ScrollEvent event) {
if (isLazyRenderingEnabled() && !autoScrollingEnabled
&& getList().getOffsetHeight() - getScrollPanel()
.getVerticalScrollPosition() <= getScrollPanel()
.getOffsetHeight()) {
int firstItemOnNextPage = getItemCount() - 1;
fillList(); //next page of data
if (firstItemOnNextPage >= 0 && firstItemOnNextPage < getItemCount()) {
autoScrollingEnabled = true;
ensureVisible(getItem(firstItemOnNextPage));
}
} else {
autoScrollingEnabled = false;
}
}
}
/**
* This handler is invoked on window resize and changes opened list popup panel position
* according to new coordinates of the {@link ComboBox}.
*/
protected class ListWindowResizeHandler implements ResizeHandler {
/** See class docs */
@Override
public void onResize(ResizeEvent resizeEvent) {
if (!isShowing()) {
return;
}
int delta = getElement().getOffsetWidth() - getElement().getClientWidth();
getScrollPanel().setWidth((getComboBox().getOffsetWidth() - delta) + "px");
adjustSize();
}
}
/**
* This handler spies for click events if the list is opened and hides it if there is any element clicked excepting
* the combo box elements and list elements.
*/
protected class ClickSpyHandler implements Event.NativePreviewHandler {
/** See class docs */
@Override
public void onPreviewNativeEvent(Event.NativePreviewEvent nativePreviewEvent) {
if (nativePreviewEvent.getTypeInt() != Event.ONCLICK) {
return;
}
Element source = Element
.as(nativePreviewEvent.getNativeEvent().getEventTarget());
if (!getElement().isOrHasChild(source)
&& !getComboBox().getElement().isOrHasChild(source)) {
hide();
getComboBox().getChoiceButton().setDown(false);
}
}
}
}