com.google.gwt.user.cellview.client.HasDataPresenter Maven / Gradle / Ivy
/*
* Copyright 2010 Google Inc.
*
* 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.gwt.user.cellview.client;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.JsArrayInteger;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;
import com.google.gwt.dom.client.Element;
import com.google.gwt.event.shared.EventHandler;
import com.google.gwt.event.shared.GwtEvent;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.safehtml.shared.SafeHtmlBuilder;
import com.google.gwt.user.cellview.client.LoadingStateChangeEvent.LoadingState;
import com.google.gwt.view.client.CellPreviewEvent;
import com.google.gwt.view.client.HasData;
import com.google.gwt.view.client.HasKeyProvider;
import com.google.gwt.view.client.ProvidesKey;
import com.google.gwt.view.client.Range;
import com.google.gwt.view.client.RangeChangeEvent;
import com.google.gwt.view.client.RowCountChangeEvent;
import com.google.gwt.view.client.SelectionChangeEvent;
import com.google.gwt.view.client.SelectionModel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
/**
*
* Presenter implementation of {@link HasData} that presents data for various
* cell based widgets. This class contains most of the shared logic used by
* these widgets, making it easier to test the common code.
*
*
* In proper MVP design, user code would interact with the presenter. However,
* that would complicate the widget code. Instead, each widget owns its own
* presenter and contains its own View. The widget forwards commands through to
* the presenter, which then updates the widget via the view. This keeps the
* user facing API simpler.
*
*
* Updates are not pushed to the view immediately. Instead, the presenter
* collects updates and resolves them all in a finally command. This reduces the
* total number of DOM manipulations, and makes it easier to handle side effects
* in user code triggered by the rendering pass. The view is responsible for
* called {@link #flush()} to force the presenter to synchronize the view when
* needed.
*
*
* @param the data type of items in the list
*/
class HasDataPresenter implements HasData, HasKeyProvider, HasKeyboardPagingPolicy {
/**
* An iterator over DOM elements.
*/
static interface ElementIterator extends Iterator {
/**
* Set the selection state of the current element.
*
* @param selected the selection state
* @throws IllegalStateException if {@link #next()} has not been called
*/
void setSelected(boolean selected) throws IllegalStateException;
}
/**
* The view that this presenter presents.
*
* @param the data type
*/
static interface View {
/**
* Add a handler to the view.
*
* @param the handler type
* @param handler the handler to add
* @param type the event type
*/
HandlerRegistration addHandler(final H handler, GwtEvent.Type type);
/**
* Replace all children with the specified values.
*
* @param values the values of the new children
* @param selectionModel the {@link SelectionModel}
* @param stealFocus true if the row should steal focus, false if not
*/
void replaceAllChildren(List values, SelectionModel super T> selectionModel,
boolean stealFocus);
/**
* Replace existing elements starting at the specified index. If the number
* of children specified exceeds the existing number of children, the
* remaining children should be appended.
*
* @param values the values of the new children
* @param start the start index to be replaced, relative to the pageStart
* @param selectionModel the {@link SelectionModel}
* @param stealFocus true if the row should steal focus, false if not
*/
void replaceChildren(List values, int start, SelectionModel super T> selectionModel,
boolean stealFocus);
/**
* Re-establish focus on an element within the view if the view already had
* focus.
*/
void resetFocus();
/**
* Update an element to reflect its keyboard selected state.
*
* @param index the index of the element relative to page start
* @param selected true if selected, false if not
* @param stealFocus true if the row should steal focus, false if not
*/
void setKeyboardSelected(int index, boolean selected, boolean stealFocus);
/**
* Set the current loading state of the data.
*
* @param state the loading state
*/
void setLoadingState(LoadingState state);
}
/**
* Represents the state of the presenter.
*
* @param the data type of the presenter
*/
private static class DefaultState implements State {
int keyboardSelectedRow = 0;
T keyboardSelectedRowValue = null;
int pageSize;
int pageStart = 0;
int rowCount = 0;
boolean rowCountIsExact = false;
final List rowData = new ArrayList();
final Set selectedRows = new HashSet();
T selectedValue = null;
boolean viewTouched;
public DefaultState(int pageSize) {
this.pageSize = pageSize;
}
@Override
public int getKeyboardSelectedRow() {
return keyboardSelectedRow;
}
@Override
public T getKeyboardSelectedRowValue() {
return keyboardSelectedRowValue;
}
@Override
public int getPageSize() {
return pageSize;
}
@Override
public int getPageStart() {
return pageStart;
}
@Override
public int getRowCount() {
return rowCount;
}
@Override
public int getRowDataSize() {
return rowData.size();
}
@Override
public T getRowDataValue(int index) {
return rowData.get(index);
}
@Override
public List getRowDataValues() {
return Collections.unmodifiableList(rowData);
}
@Override
public T getSelectedValue() {
return selectedValue;
}
@Override
public boolean isRowCountExact() {
return rowCountIsExact;
}
/**
* {@inheritDoc}
*
*
* The set of selected rows is not maintained in the pending state. This
* method should only be called on the state after it has been resolved.
*
*/
@Override
public boolean isRowSelected(int index) {
return selectedRows.contains(index);
}
@Override
public boolean isViewTouched() {
return viewTouched;
}
}
/**
* Represents the pending state of the presenter.
*
* @param the data type of the presenter
*/
private static class PendingState extends DefaultState {
/**
* A boolean indicating that the user has keyboard selected a new row.
*/
private boolean keyboardSelectedRowChanged;
/**
* A boolean indicating that a change in keyboard selected should cause us
* to steal focus.
*/
private boolean keyboardStealFocus = false;
/**
* Set to true if a redraw is required.
*/
private boolean redrawRequired = false;
/**
* The list of ranges that have been replaced.
*/
private final List replacedRanges = new ArrayList();
public PendingState(State state) {
super(state.getPageSize());
this.keyboardSelectedRow = state.getKeyboardSelectedRow();
this.keyboardSelectedRowValue = state.getKeyboardSelectedRowValue();
this.pageSize = state.getPageSize();
this.pageStart = state.getPageStart();
this.rowCount = state.getRowCount();
this.rowCountIsExact = state.isRowCountExact();
this.selectedValue = state.getSelectedValue();
this.viewTouched = state.isViewTouched();
// Copy the row data.
int rowDataSize = state.getRowDataSize();
for (int i = 0; i < rowDataSize; i++) {
this.rowData.add(state.getRowDataValue(i));
}
/*
* We do not copy the selected rows from the old state. They will be
* resolved from the SelectionModel.
*/
}
/**
* Update the range of replaced data.
*
* @param start the start index
* @param end the end index
*/
public void replaceRange(int start, int end) {
replacedRanges.add(new Range(start, end - start));
}
}
/**
* Represents the state of the presenter.
*
* @param the data type of the presenter
*/
private static interface State {
/**
* Get the current keyboard selected row relative to page start. This value
* should never be negative.
*/
int getKeyboardSelectedRow();
/**
* Get the last row value that was selected with the keyboard.
*/
T getKeyboardSelectedRowValue();
/**
* Get the number of rows in the current page.
*/
int getPageSize();
/**
* Get the absolute start index of the page.
*/
int getPageStart();
/**
* Get the total number of rows.
*/
int getRowCount();
/**
* Get the size of the row data.
*/
int getRowDataSize();
/**
* Get a specific value from the row data.
*/
T getRowDataValue(int index);
/**
* Get all of the row data values in an unmodifiable list.
*/
List getRowDataValues();
/**
* Get the value that is selected in the {@link SelectionModel}.
*/
T getSelectedValue();
/**
* Get a boolean indicating whether the row count is exact or an estimate.
*/
boolean isRowCountExact();
/**
* Check if a row index is selected.
*
* @param index the row index
* @return true if selected, false if not
*/
boolean isRowSelected(int index);
/**
* Check if the user interacted with the view at some point. Selection is
* not bound to the keyboard selected row until the view is touched. Once
* touched, selection is bound from then on.
*/
boolean isViewTouched();
}
/**
* The number of rows to jump when PAGE_UP or PAGE_DOWN is pressed and the
* {@link HasKeyboardPagingPolicy.KeyboardPagingPolicy} is
* {@link HasKeyboardPagingPolicy.KeyboardPagingPolicy#INCREASE_RANGE}.
*/
static final int PAGE_INCREMENT = 30;
/**
* The maximum number of times we can try to
* {@link #resolvePendingState(JsArrayInteger)} before we assume there is an
* infinite loop.
*/
private static final int LOOP_MAXIMUM = 10;
/**
* The minimum number of rows that need to be replaced before we do a redraw.
*/
private static final int REDRAW_MINIMUM = 5;
/**
* The threshold of new data after which we redraw the entire view instead of
* replacing specific rows.
*
* TODO(jlabanca): Find the optimal value for the threshold.
*/
private static final double REDRAW_THRESHOLD = 0.30;
/**
* Sort a native integer array numerically.
*
* @param array the array to sort
*/
private static native void sortJsArrayInteger(JsArrayInteger array) /*-{
// sort() sorts lexicographically by default.
array.sort(function(x, y) {
return x - y;
});
}-*/;
private final HasData display;
/**
* A boolean indicating that we are in the process of resolving state.
*/
private boolean isResolvingState;
private KeyboardPagingPolicy keyboardPagingPolicy = KeyboardPagingPolicy.CHANGE_PAGE;
private KeyboardSelectionPolicy keyboardSelectionPolicy = KeyboardSelectionPolicy.ENABLED;
private final ProvidesKey keyProvider;
/**
* The pending state of the presenter to be pushed to the view.
*/
private PendingState pendingState;
/**
* The command used to resolve the pending state.
*/
private ScheduledCommand pendingStateCommand;
/**
* A counter used to detect infinite loops in
* {@link #resolvePendingState(JsArrayInteger)}. An infinite loop can occur if
* user code, such as reading the {@link SelectionModel}, causes the table to
* have a pending state.
*/
private int pendingStateLoop = 0;
private HandlerRegistration selectionHandler;
private SelectionModel super T> selectionModel;
/**
* The current state of the presenter reflected in the view. We intentionally
* use the interface, which only has getters, to ensure that we do not
* accidently modify the current state.
*/
private State state;
private final View view;
/**
* Construct a new {@link HasDataPresenter}.
*
* @param display the display that is being presented
* @param view the view implementation
* @param pageSize the default page size
*/
public HasDataPresenter(HasData display, View view, int pageSize, ProvidesKey keyProvider) {
this.display = display;
this.view = view;
this.keyProvider = keyProvider;
this.state = new DefaultState(pageSize);
}
@Override
public HandlerRegistration addCellPreviewHandler(CellPreviewEvent.Handler handler) {
return view.addHandler(handler, CellPreviewEvent.getType());
}
public HandlerRegistration addLoadingStateChangeHandler(LoadingStateChangeEvent.Handler handler) {
return view.addHandler(handler, LoadingStateChangeEvent.TYPE);
}
@Override
public HandlerRegistration addRangeChangeHandler(RangeChangeEvent.Handler handler) {
return view.addHandler(handler, RangeChangeEvent.getType());
}
@Override
public HandlerRegistration addRowCountChangeHandler(RowCountChangeEvent.Handler handler) {
return view.addHandler(handler, RowCountChangeEvent.getType());
}
/**
* Clear the row value associated with the keyboard selected row.
*/
public void clearKeyboardSelectedRowValue() {
if (getKeyboardSelectedRowValue() != null) {
ensurePendingState().keyboardSelectedRowValue = null;
}
}
/**
* Clear the {@link SelectionModel} without updating the view.
*/
public void clearSelectionModel() {
if (selectionHandler != null) {
selectionHandler.removeHandler();
selectionHandler = null;
}
selectionModel = null;
}
/**
* @throws UnsupportedOperationException
*/
@Override
public void fireEvent(GwtEvent> event) {
// HasData should fire their own events.
throw new UnsupportedOperationException();
}
/**
* Flush pending changes to the view.
*/
public void flush() {
resolvePendingState(null);
}
/**
* Get the current page size. This is usually the page size, but can be less
* if the data size cannot fill the current page.
*
* @return the size of the current page
*/
public int getCurrentPageSize() {
return Math.min(getPageSize(), getRowCount() - getPageStart());
}
@Override
public KeyboardPagingPolicy getKeyboardPagingPolicy() {
return keyboardPagingPolicy;
}
/**
* Get the index of the keyboard selected row relative to the page start.
*
* @return the row index, or -1 if disabled
*/
public int getKeyboardSelectedRow() {
return KeyboardSelectionPolicy.DISABLED == keyboardSelectionPolicy ? -1 : getCurrentState()
.getKeyboardSelectedRow();
}
/**
* Get the index of the keyboard selected row relative to the page start as it
* appears in the view, regardless of whether or not there is a pending
* change.
*
* @return the row index, or -1 if disabled
*/
public int getKeyboardSelectedRowInView() {
return KeyboardSelectionPolicy.DISABLED == keyboardSelectionPolicy ? -1 : state
.getKeyboardSelectedRow();
}
/**
* Get the value that the user selected.
*
* @return the value, or null if a value was not selected
*/
public T getKeyboardSelectedRowValue() {
return KeyboardSelectionPolicy.DISABLED == keyboardSelectionPolicy ? null : getCurrentState()
.getKeyboardSelectedRowValue();
}
@Override
public KeyboardSelectionPolicy getKeyboardSelectionPolicy() {
return keyboardSelectionPolicy;
}
@Override
public ProvidesKey getKeyProvider() {
return keyProvider;
}
/**
* Get the overall data size.
*
* @return the data size
*/
@Override
public int getRowCount() {
return getCurrentState().getRowCount();
}
@Override
public SelectionModel super T> getSelectionModel() {
return selectionModel;
}
@Override
public T getVisibleItem(int indexOnPage) {
return getCurrentState().getRowDataValue(indexOnPage);
}
@Override
public int getVisibleItemCount() {
return getCurrentState().getRowDataSize();
}
@Override
public List getVisibleItems() {
return getCurrentState().getRowDataValues();
}
/**
* Return the range of data being displayed.
*/
@Override
public Range getVisibleRange() {
return new Range(getPageStart(), getPageSize());
}
/**
* Check whether or not there is a pending state. If there is a pending state,
* views might skip DOM updates and wait for the new data to be rendered when
* the pending state is resolved.
*
* @return true if there is a pending state, false if not
*/
public boolean hasPendingState() {
return pendingState != null;
}
/**
* Check whether or not the data set is empty. That is, the row count is
* exactly 0.
*
* @return true if data set is empty
*/
public boolean isEmpty() {
return isRowCountExact() && getRowCount() == 0;
}
@Override
public boolean isRowCountExact() {
return getCurrentState().isRowCountExact();
}
/**
* Redraw the list with the current data.
*/
public void redraw() {
ensurePendingState().redrawRequired = true;
}
@Override
public void setKeyboardPagingPolicy(KeyboardPagingPolicy policy) {
if (policy == null) {
throw new NullPointerException("KeyboardPagingPolicy cannot be null");
}
this.keyboardPagingPolicy = policy;
}
/**
* Set the row index of the keyboard selected element.
*
* @param index the row index
* @param stealFocus true to steal focus
* @param forceUpdate force the update even if the row didn't change
*/
public void setKeyboardSelectedRow(int index, boolean stealFocus, boolean forceUpdate) {
// Early exit if disabled.
if (KeyboardSelectionPolicy.DISABLED == keyboardSelectionPolicy) {
return;
}
// Clip the row index if the paging policy is limited.
if (keyboardPagingPolicy.isLimitedToRange()) {
// index will be 0 if visible item count is 0.
index = Math.max(0, Math.min(index, getVisibleItemCount() - 1));
}
// The user touched the view.
ensurePendingState().viewTouched = true;
/*
* Early exit if the keyboard selected row has not changed and the keyboard
* selected value is already set.
*/
if (!forceUpdate && getKeyboardSelectedRow() == index && getKeyboardSelectedRowValue() != null) {
return;
}
// Trim to within bounds.
int pageStart = getPageStart();
int pageSize = getPageSize();
int rowCount = getRowCount();
int absIndex = pageStart + index;
if (absIndex >= rowCount && isRowCountExact()) {
absIndex = rowCount - 1;
}
index = Math.max(0, absIndex) - pageStart;
if (keyboardPagingPolicy.isLimitedToRange()) {
index = Math.max(0, Math.min(index, pageSize - 1));
}
// Select the new index.
int newPageStart = pageStart;
int newPageSize = pageSize;
PendingState pending = ensurePendingState();
pending.keyboardSelectedRow = 0;
pending.keyboardSelectedRowValue = null;
pending.keyboardSelectedRowChanged = true;
if (index >= 0 && index < pageSize) {
pending.keyboardSelectedRow = index;
pending.keyboardSelectedRowValue =
index < pending.getRowDataSize() ? ensurePendingState().getRowDataValue(index) : null;
pending.keyboardStealFocus = stealFocus;
return;
} else if (KeyboardPagingPolicy.CHANGE_PAGE == keyboardPagingPolicy) {
// Go to previous page.
while (index < 0) {
int shift = Math.min(pageSize, newPageStart);
newPageStart -= shift;
index += shift;
}
// Go to next page.
while (index >= pageSize) {
newPageStart += pageSize;
index -= pageSize;
}
} else if (KeyboardPagingPolicy.INCREASE_RANGE == keyboardPagingPolicy) {
// Increase range at the beginning.
while (index < 0) {
int shift = Math.min(PAGE_INCREMENT, newPageStart);
newPageSize += shift;
newPageStart -= shift;
index += shift;
}
// Increase range at the end.
while (index >= newPageSize) {
newPageSize += PAGE_INCREMENT;
}
if (isRowCountExact()) {
newPageSize = Math.min(newPageSize, rowCount - newPageStart);
if (index >= rowCount) {
index = rowCount - 1;
}
}
}
// Update the range if it changed.
if (newPageStart != pageStart || newPageSize != pageSize) {
pending.keyboardSelectedRow = index;
setVisibleRange(new Range(newPageStart, newPageSize), false, false);
}
}
@Override
public void setKeyboardSelectionPolicy(KeyboardSelectionPolicy policy) {
if (policy == null) {
throw new NullPointerException("KeyboardSelectionPolicy cannot be null");
}
this.keyboardSelectionPolicy = policy;
}
/**
* @throws UnsupportedOperationException
*/
@Override
public final void setRowCount(int count) {
// Views should defer to their own implementation of
// setRowCount(int, boolean)) per HasRows spec.
throw new UnsupportedOperationException();
}
@Override
public void setRowCount(int count, boolean isExact) {
if (count == getRowCount() && isExact == isRowCountExact()) {
return;
}
ensurePendingState().rowCount = count;
ensurePendingState().rowCountIsExact = isExact;
// Update the cached data.
updateCachedData();
// Update the pager.
RowCountChangeEvent.fire(display, count, isExact);
}
@Override
public void setRowData(int start, List extends T> values) {
int valuesLength = values.size();
int valuesEnd = start + valuesLength;
// Calculate the bounded start (inclusive) and end index (exclusive).
int pageStart = getPageStart();
int pageEnd = getPageStart() + getPageSize();
int boundedStart = Math.max(start, pageStart);
int boundedEnd = Math.min(valuesEnd, pageEnd);
if (start != pageStart && boundedStart >= boundedEnd) {
// The data is out of range for the current page.
// Intentionally allow empty lists that start on the page start.
return;
}
// Create placeholders up to the specified index.
PendingState pending = ensurePendingState();
int cacheOffset = Math.max(0, boundedStart - pageStart - getVisibleItemCount());
for (int i = 0; i < cacheOffset; i++) {
pending.rowData.add(null);
}
// Insert the new values into the data array.
for (int i = boundedStart; i < boundedEnd; i++) {
T value = values.get(i - start);
int dataIndex = i - pageStart;
if (dataIndex < getVisibleItemCount()) {
pending.rowData.set(dataIndex, value);
} else {
pending.rowData.add(value);
}
}
// Remember the range that has been replaced.
pending.replaceRange(boundedStart - cacheOffset, boundedEnd);
// Fire a row count change event after updating the data.
if (valuesEnd > getRowCount()) {
setRowCount(valuesEnd, isRowCountExact());
}
}
@Override
public void setSelectionModel(final SelectionModel super T> selectionModel) {
clearSelectionModel();
// Set the new selection model.
this.selectionModel = selectionModel;
if (selectionModel != null) {
selectionHandler =
selectionModel.addSelectionChangeHandler(new SelectionChangeEvent.Handler() {
@Override
public void onSelectionChange(SelectionChangeEvent event) {
// Ensure that we resolve selection.
ensurePendingState();
}
});
}
// Update the current selection state based on the new model.
ensurePendingState();
}
/**
* @throws UnsupportedOperationException
*/
@Override
public final void setVisibleRange(int start, int length) {
// Views should defer to their own implementation of setVisibleRange(Range)
// per HasRows spec.
throw new UnsupportedOperationException();
}
@Override
public void setVisibleRange(Range range) {
setVisibleRange(range, false, false);
}
@Override
public void setVisibleRangeAndClearData(Range range, boolean forceRangeChangeEvent) {
setVisibleRange(range, true, forceRangeChangeEvent);
}
/**
* Schedules the command.
*
*
* Protected so that subclasses can override to use an alternative scheduler.
*
*
* @param command the command to execute
*/
protected void scheduleFinally(ScheduledCommand command) {
Scheduler.get().scheduleFinally(command);
}
/**
* Combine the modified row indexes into as many as two {@link Range}s,
* optimizing to have the fewest unmodified rows within the ranges. Using two
* ranges covers the most common use cases of selecting one row, selecting a
* range, moving selection from one row to another, or moving keyboard
* selection.
*
*
* Visible for testing.
*
*
*
* This method has the side effect of sorting the modified rows.
*
*
* @param modifiedRows the unordered indexes of modified rows
* @return up to two ranges that encompass the modified rows
*/
List calculateModifiedRanges(JsArrayInteger modifiedRows, int pageStart, int pageEnd) {
sortJsArrayInteger(modifiedRows);
int rangeStart0 = -1;
int rangeEnd0 = -1;
int rangeStart1 = -1;
int rangeEnd1 = -1;
int maxDiff = 0;
for (int i = 0; i < modifiedRows.length(); i++) {
int index = modifiedRows.get(i);
if (index < pageStart || index >= pageEnd) {
// The index is out of range of the current page.
continue;
} else if (rangeStart0 == -1) {
// Range0 defaults to the first index.
rangeStart0 = index;
rangeEnd0 = index;
} else if (rangeStart1 == -1) {
// Range1 defaults to the second index.
maxDiff = index - rangeEnd0;
rangeStart1 = index;
rangeEnd1 = index;
} else {
int diff = index - rangeEnd1;
if (diff > maxDiff) {
// Move the old range1 onto range0 and start range1 from this index.
rangeEnd0 = rangeEnd1;
rangeStart1 = index;
rangeEnd1 = index;
maxDiff = diff;
} else {
// Add this index to range1.
rangeEnd1 = index;
}
}
}
// Convert the range ends to exclusive indexes for calculations.
rangeEnd0 += 1;
rangeEnd1 += 1;
// Combine the ranges if they are continuous.
if (rangeStart1 == rangeEnd0) {
rangeEnd0 = rangeEnd1;
rangeStart1 = -1;
rangeEnd1 = -1;
}
// Return the ranges.
List toRet = new ArrayList();
if (rangeStart0 != -1) {
int rangeLength0 = rangeEnd0 - rangeStart0;
toRet.add(new Range(rangeStart0, rangeLength0));
}
if (rangeStart1 != -1) {
int rangeLength1 = rangeEnd1 - rangeStart1;
toRet.add(new Range(rangeStart1, rangeLength1));
}
return toRet;
}
/**
* Ensure that a pending {@link DefaultState} exists and return it.
*
* @return the pending state
*/
private PendingState ensurePendingState() {
// Create the pending state if needed.
if (pendingState == null) {
pendingState = new PendingState(state);
}
/*
* Schedule a command to resolve the pending state. If a command is already
* scheduled, we reschedule a new one to ensure that it happens after any
* existing finally commands (such as SelectionModel commands).
*/
pendingStateCommand = new ScheduledCommand() {
@Override
public void execute() {
// Verify that this command was the last one scheduled.
if (pendingStateCommand == this) {
resolvePendingState(null);
}
}
};
scheduleFinally(pendingStateCommand);
// Return the pending state.
return pendingState;
}
/**
* Find the index within the {@link State} of the best match for the specified
* row value. The best match is a row value with the same key, closest to the
* initial index.
*
* @param state the state to search
* @param value the value to find
* @param initialIndex the initial index of the value
* @return the best match index, or -1 if not found
*/
private int findIndexOfBestMatch(State state, T value, int initialIndex) {
// Get the key for the value.
Object key = getRowValueKey(value);
if (key == null) {
return -1;
}
int bestMatchIndex = -1;
int bestMatchDiff = Integer.MAX_VALUE;
int rowDataCount = state.getRowDataSize();
for (int i = 0; i < rowDataCount; i++) {
T curValue = state.getRowDataValue(i);
Object curKey = getRowValueKey(curValue);
if (key.equals(curKey)) {
int diff = Math.abs(initialIndex - i);
if (diff < bestMatchDiff) {
bestMatchIndex = i;
bestMatchDiff = diff;
}
}
}
return bestMatchIndex;
}
/**
* Get the current state of the presenter.
*
* @return the pending state if one exists, otherwise the state
*/
private State getCurrentState() {
return pendingState == null ? state : pendingState;
}
private int getPageSize() {
return getCurrentState().getPageSize();
}
private int getPageStart() {
return getCurrentState().getPageStart();
}
/**
* Get the key for the specified row value.
*
* @param rowValue the row value
* @return the key
*/
private Object getRowValueKey(T rowValue) {
return (keyProvider == null || rowValue == null) ? rowValue : keyProvider.getKey(rowValue);
}
/**
* Resolve the pending state and push updates to the view.
*
* @param modifiedRows the modified rows that need to be updated, or null if
* none. The modified rows may be mutated.
* @return true if the state changed, false if not
*/
private boolean resolvePendingState(JsArrayInteger modifiedRows) {
pendingStateCommand = null;
/*
* We are already resolving state. New changes will be flushed after the
* current flush is finished.
*/
if (isResolvingState) {
return false;
}
isResolvingState = true;
// Early exit if there is no pending state.
if (pendingState == null) {
isResolvingState = false;
pendingStateLoop = 0;
return false;
}
/*
* Check for an infinite loop. This can happen if user code accessed in this
* method modifies the pending state and flushes changes.
*/
pendingStateLoop++;
if (pendingStateLoop > LOOP_MAXIMUM) {
isResolvingState = false;
pendingStateLoop = 0; // Let user code handle exception and try again.
throw new IllegalStateException(
"A possible infinite loop has been detected in a Cell Widget. This "
+ "usually happens when your SelectionModel triggers a "
+ "SelectionChangeEvent when SelectionModel.isSelection() is "
+ "called, which causes the table to redraw continuously.");
}
/*
* Swap the states in case user code triggers more changes, which will
* create a new pendingState.
*/
State oldState = state;
PendingState newState = pendingState;
state = pendingState;
pendingState = null;
/*
* Keep track of the absolute indexes of modified rows.
*
* Use a native array to avoid dynamic casts associated with emulated Java
* Collections.
*/
if (modifiedRows == null) {
modifiedRows = JavaScriptObject.createArray().cast();
}
// Get the values used for calculations.
int pageStart = newState.getPageStart();
int pageSize = newState.getPageSize();
int pageEnd = pageStart + pageSize;
int rowDataCount = newState.getRowDataSize();
/*
* Resolve keyboard selection. If the row value still exists, use its index.
* If the row value exists in multiple places, use the closest index. If the
* row value no longer exists, use the current index.
*/
newState.keyboardSelectedRow =
Math.max(0, Math.min(newState.keyboardSelectedRow, rowDataCount - 1));
if (KeyboardSelectionPolicy.DISABLED == keyboardSelectionPolicy) {
// Clear the keyboard selected state.
newState.keyboardSelectedRow = 0;
newState.keyboardSelectedRowValue = null;
} else if (newState.keyboardSelectedRowChanged) {
// Choose the row value based on the index.
newState.keyboardSelectedRowValue =
rowDataCount > 0 ? newState.getRowDataValue(newState.keyboardSelectedRow) : null;
} else if (newState.keyboardSelectedRowValue != null) {
// Choose the index based on the row value.
int bestMatchIndex =
findIndexOfBestMatch(newState, newState.keyboardSelectedRowValue,
newState.keyboardSelectedRow);
if (bestMatchIndex >= 0) {
// A match was found.
newState.keyboardSelectedRow = bestMatchIndex;
newState.keyboardSelectedRowValue =
rowDataCount > 0 ? newState.getRowDataValue(newState.keyboardSelectedRow) : null;
} else {
// No match was found, so reset to 0.
newState.keyboardSelectedRow = 0;
newState.keyboardSelectedRowValue = null;
}
}
/*
* Update the SelectionModel based on the keyboard selected value. We only
* bind to selection after the user has interacted with the widget at least
* once. This prevents values from being selected by default.
*/
try {
if (KeyboardSelectionPolicy.BOUND_TO_SELECTION == keyboardSelectionPolicy
&& selectionModel != null && newState.viewTouched) {
T oldValue = oldState.getSelectedValue();
Object oldKey = getRowValueKey(oldValue);
T newValue =
rowDataCount > 0 ? newState.getRowDataValue(newState.getKeyboardSelectedRow()) : null;
Object newKey = getRowValueKey(newValue);
/*
* Do not deselect the old value unless we have a new value to select,
* or we will have a null selection event while we wait for asynchronous
* data to load.
*/
if (newKey != null) {
// Check both values for selection before setting selection, or the
// selection model may resolve state early.
boolean oldValueWasSelected =
(oldValue == null) ? false : selectionModel.isSelected(oldValue);
boolean newValueWasSelected =
(newValue == null) ? false : selectionModel.isSelected(newValue);
if (!newKey.equals(oldKey)) {
// Deselect the old value.
if (oldValueWasSelected) {
selectionModel.setSelected(oldValue, false);
}
// Select the new value.
newState.selectedValue = newValue;
if (newValue != null && !newValueWasSelected) {
selectionModel.setSelected(newValue, true);
}
} else if (!newValueWasSelected) {
// The value was programmatically deselected.
newState.selectedValue = null;
}
}
}
} catch (RuntimeException e) {
// Unlock the rendering loop if the user SelectionModel throw an error.
isResolvingState = false;
pendingStateLoop = 0;
throw e;
}
// If the keyboard row changes, add it to the modified set.
boolean keyboardRowChanged =
newState.keyboardSelectedRowChanged
|| (oldState.getKeyboardSelectedRow() != newState.keyboardSelectedRow)
|| (oldState.getKeyboardSelectedRowValue() == null && newState.keyboardSelectedRowValue != null);
/*
* Resolve selection. Check the selection status of all row values in the
* pending state and compare them to the status in the old state. If we know
* longer have a SelectionModel but had selected rows, we still need to
* update the rows.
*/
Set newlySelectedRows = new HashSet();
try {
for (int i = pageStart; i < pageStart + rowDataCount; i++) {
// Check the new selection state.
T rowValue = newState.getRowDataValue(i - pageStart);
boolean isSelected =
(rowValue != null && selectionModel != null && selectionModel.isSelected(rowValue));
// Compare to the old selection state.
boolean wasSelected = oldState.isRowSelected(i);
if (isSelected) {
newState.selectedRows.add(i);
newlySelectedRows.add(i);
if (!wasSelected) {
modifiedRows.push(i);
}
} else if (wasSelected) {
modifiedRows.push(i);
}
}
} catch (RuntimeException e) {
// Unlock the rendering loop if the user SelectionModel throw an error.
isResolvingState = false;
pendingStateLoop = 0;
throw e;
}
// Add the replaced ranges as modified rows.
boolean replacedEmptyRange = false;
for (Range replacedRange : newState.replacedRanges) {
int start = replacedRange.getStart();
int length = replacedRange.getLength();
// If the user set an empty range, pass it through to the view.
if (length == 0) {
replacedEmptyRange = true;
}
for (int i = start; i < start + length; i++) {
modifiedRows.push(i);
}
}
// Add keyboard rows to modified rows if we are going to render anyway.
if (modifiedRows.length() > 0 && keyboardRowChanged) {
modifiedRows.push(oldState.getKeyboardSelectedRow());
modifiedRows.push(newState.keyboardSelectedRow);
}
/*
* We called methods in user code that could modify the view, so early exit
* if there is a new pending state waiting to be resolved.
*/
if (pendingState != null) {
isResolvingState = false;
// Do not reset pendingStateLoop, or we will not detect the infinite loop.
// Propagate modifications to the temporary pending state into the new
// pending state instance.
pendingState.selectedValue = newState.selectedValue;
pendingState.selectedRows.addAll(newlySelectedRows);
if (keyboardRowChanged) {
pendingState.keyboardSelectedRowChanged = true;
}
if (newState.keyboardStealFocus) {
pendingState.keyboardStealFocus = true;
}
/*
* Add the keyboard selected rows to the modified rows so they can be
* re-rendered in the new state. These rows may already be added, but
* modifiedRows can contain duplicates.
*/
modifiedRows.push(oldState.getKeyboardSelectedRow());
modifiedRows.push(newState.keyboardSelectedRow);
/*
* Make a recursive call to resolve the state again, using the new pending
* state that was just created. If we are successful, then the modified
* rows will be redrawn. If we are not successful, then we still need to
* redraw the modified rows.
*/
if (resolvePendingState(modifiedRows)) {
return true;
}
}
// Calculate the modified ranges.
List modifiedRanges = calculateModifiedRanges(modifiedRows, pageStart, pageEnd);
Range range0 = modifiedRanges.size() > 0 ? modifiedRanges.get(0) : null;
Range range1 = modifiedRanges.size() > 1 ? modifiedRanges.get(1) : null;
int replaceDiff = 0; // The total number of rows to replace.
for (Range range : modifiedRanges) {
replaceDiff += range.getLength();
}
/*
* Check the various conditions that require redraw.
*/
int oldPageStart = oldState.getPageStart();
int oldPageSize = oldState.getPageSize();
int oldRowDataCount = oldState.getRowDataSize();
boolean redrawRequired = newState.redrawRequired;
if (pageStart != oldPageStart) {
// Redraw if pageStart changes.
redrawRequired = true;
} else if (rowDataCount < oldRowDataCount) {
// Redraw if we have trimmed the row data.
redrawRequired = true;
} else if (range1 == null && range0 != null && range0.getStart() == pageStart
&& (replaceDiff >= oldRowDataCount || replaceDiff > oldPageSize)) {
// Redraw if the new data completely overlaps the old data.
redrawRequired = true;
} else if (replaceDiff >= REDRAW_MINIMUM && replaceDiff > REDRAW_THRESHOLD * oldRowDataCount) {
/*
* Redraw if the number of modified rows represents a large portion of the
* view, defined as greater than 30% of the rows (minimum of 5).
*/
redrawRequired = true;
} else if (replacedEmptyRange && oldRowDataCount == 0) {
/*
* If the user replaced an empty range, pass it to the view. This is a
* useful edge case that provides consistency in the way data is pushed to
* the view.
*/
redrawRequired = true;
}
// Update the loading state in the view.
updateLoadingState();
/*
* Push changes to the view.
*/
try {
if (redrawRequired) {
// Redraw the entire content.
SafeHtmlBuilder sb = new SafeHtmlBuilder();
view.replaceAllChildren(newState.rowData, selectionModel, newState.keyboardStealFocus);
view.resetFocus();
} else if (range0 != null) {
// Surgically replace specific rows.
// Replace range0.
{
int absStart = range0.getStart();
int relStart = absStart - pageStart;
SafeHtmlBuilder sb = new SafeHtmlBuilder();
List replaceValues = newState.rowData.subList(relStart, relStart + range0.getLength());
view.replaceChildren(replaceValues, relStart, selectionModel, newState.keyboardStealFocus);
}
// Replace range1 if it exists.
if (range1 != null) {
int absStart = range1.getStart();
int relStart = absStart - pageStart;
SafeHtmlBuilder sb = new SafeHtmlBuilder();
List replaceValues = newState.rowData.subList(relStart, relStart + range1.getLength());
view.replaceChildren(replaceValues, relStart, selectionModel, newState.keyboardStealFocus);
}
view.resetFocus();
} else if (keyboardRowChanged) {
// Update the keyboard selected rows without redrawing.
// Deselect the old keyboard row.
int oldSelectedRow = oldState.getKeyboardSelectedRow();
if (oldSelectedRow >= 0 && oldSelectedRow < rowDataCount) {
view.setKeyboardSelected(oldSelectedRow, false, false);
}
// Select the new keyboard row.
int newSelectedRow = newState.getKeyboardSelectedRow();
if (newSelectedRow >= 0 && newSelectedRow < rowDataCount) {
view.setKeyboardSelected(newSelectedRow, true, newState.keyboardStealFocus);
}
}
} catch (Error e) {
// Force the error into the dev mode console.
throw new RuntimeException(e);
} finally {
/*
* We are done resolving state, so unlock the rendering loop. We unlock
* the loop even if user rendering code throws an error to avoid throwing
* an additional, misleading IllegalStateException.
*/
isResolvingState = false;
}
/*
* Make a recursive call to resolve any pending state. We don't expect
* pending state here, but its always possible that pushing the changes into
* the view could update the presenter. If there is no new state, the
* recursive call will reset the pendingStateLoop.
*/
resolvePendingState(null);
return true;
}
/**
* Set the visible {@link Range}, optionally clearing data and/or firing a
* {@link RangeChangeEvent}.
*
* @param range the new {@link Range}
* @param clearData true to clear all data
* @param forceRangeChangeEvent true to force a {@link RangeChangeEvent}
*/
private void setVisibleRange(Range range, boolean clearData, boolean forceRangeChangeEvent) {
final int start = range.getStart();
final int length = range.getLength();
if (start < 0) {
throw new IllegalArgumentException("Range start cannot be less than 0");
}
if (length < 0) {
throw new IllegalArgumentException("Range length cannot be less than 0");
}
// Update the page start.
final int pageStart = getPageStart();
final int pageSize = getPageSize();
final boolean pageStartChanged = (pageStart != start);
if (pageStartChanged) {
PendingState pending = ensurePendingState();
// Trim the data if we aren't clearing it.
if (!clearData) {
if (start > pageStart) {
int increase = start - pageStart;
if (getVisibleItemCount() > increase) {
// Remove the data we no longer need.
for (int i = 0; i < increase; i++) {
pending.rowData.remove(0);
}
} else {
// We have no overlapping data, so just clear it.
pending.rowData.clear();
}
} else {
int decrease = pageStart - start;
if ((getVisibleItemCount() > 0) && (decrease < pageSize)) {
// Insert null data at the beginning.
for (int i = 0; i < decrease; i++) {
pending.rowData.add(0, null);
}
// Remember the inserted range because we might return to the same
// pageStart in this event loop, which means we won't do a full
// redraw, but still need to replace the inserted nulls in the view.
pending.replaceRange(start, start + decrease);
} else {
// We have no overlapping data, so just clear it.
pending.rowData.clear();
}
}
}
// Update the page start.
pending.pageStart = start;
}
// Update the page size.
final boolean pageSizeChanged = (pageSize != length);
if (pageSizeChanged) {
ensurePendingState().pageSize = length;
}
// Clear the data.
if (clearData) {
ensurePendingState().rowData.clear();
}
// Trim the row values if needed.
updateCachedData();
// Update the pager and data source if the range changed.
if (pageStartChanged || pageSizeChanged || forceRangeChangeEvent) {
RangeChangeEvent.fire(display, getVisibleRange());
}
}
/**
* Ensure that the cached data is consistent with the data size.
*/
private void updateCachedData() {
int pageStart = getPageStart();
int expectedLastIndex = Math.max(0, Math.min(getPageSize(), getRowCount() - pageStart));
int lastIndex = getVisibleItemCount() - 1;
while (lastIndex >= expectedLastIndex) {
ensurePendingState().rowData.remove(lastIndex);
lastIndex--;
}
}
/**
* Update the loading state of the view based on the data size and page size.
*/
private void updateLoadingState() {
int cacheSize = getVisibleItemCount();
int curPageSize = isRowCountExact() ? getCurrentPageSize() : getPageSize();
if (cacheSize >= curPageSize) {
view.setLoadingState(LoadingState.LOADED);
} else if (cacheSize == 0) {
view.setLoadingState(LoadingState.LOADING);
} else {
view.setLoadingState(LoadingState.PARTIALLY_LOADED);
}
}
}