com.vaadin.client.widgets.Grid Maven / Gradle / Ivy
Show all versions of vaadin-client Show documentation
/*
* Copyright 2000-2014 Vaadin Ltd.
*
* 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.vaadin.client.widgets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;
import com.google.gwt.core.shared.GWT;
import com.google.gwt.dom.client.BrowserEvents;
import com.google.gwt.dom.client.DivElement;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.EventTarget;
import com.google.gwt.dom.client.Style;
import com.google.gwt.dom.client.Style.Unit;
import com.google.gwt.dom.client.TableCellElement;
import com.google.gwt.dom.client.TableRowElement;
import com.google.gwt.dom.client.TableSectionElement;
import com.google.gwt.dom.client.Touch;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.event.dom.client.KeyCodes;
import com.google.gwt.event.dom.client.KeyEvent;
import com.google.gwt.event.dom.client.MouseEvent;
import com.google.gwt.event.logical.shared.ValueChangeEvent;
import com.google.gwt.event.logical.shared.ValueChangeHandler;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.touch.client.Point;
import com.google.gwt.user.client.DOM;
import com.google.gwt.user.client.Event;
import com.google.gwt.user.client.Timer;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.CheckBox;
import com.google.gwt.user.client.ui.HasEnabled;
import com.google.gwt.user.client.ui.HasWidgets;
import com.google.gwt.user.client.ui.ResizeComposite;
import com.google.gwt.user.client.ui.Widget;
import com.vaadin.client.BrowserInfo;
import com.vaadin.client.DeferredWorker;
import com.vaadin.client.WidgetUtil;
import com.vaadin.client.data.DataChangeHandler;
import com.vaadin.client.data.DataSource;
import com.vaadin.client.renderers.ComplexRenderer;
import com.vaadin.client.renderers.Renderer;
import com.vaadin.client.renderers.WidgetRenderer;
import com.vaadin.client.ui.SubPartAware;
import com.vaadin.client.widget.escalator.Cell;
import com.vaadin.client.widget.escalator.ColumnConfiguration;
import com.vaadin.client.widget.escalator.EscalatorUpdater;
import com.vaadin.client.widget.escalator.FlyweightCell;
import com.vaadin.client.widget.escalator.Row;
import com.vaadin.client.widget.escalator.RowContainer;
import com.vaadin.client.widget.escalator.RowVisibilityChangeEvent;
import com.vaadin.client.widget.escalator.RowVisibilityChangeHandler;
import com.vaadin.client.widget.escalator.ScrollbarBundle.Direction;
import com.vaadin.client.widget.grid.CellReference;
import com.vaadin.client.widget.grid.CellStyleGenerator;
import com.vaadin.client.widget.grid.DataAvailableEvent;
import com.vaadin.client.widget.grid.DataAvailableHandler;
import com.vaadin.client.widget.grid.EditorHandler;
import com.vaadin.client.widget.grid.EditorHandler.EditorRequest;
import com.vaadin.client.widget.grid.EventCellReference;
import com.vaadin.client.widget.grid.RendererCellReference;
import com.vaadin.client.widget.grid.RowReference;
import com.vaadin.client.widget.grid.RowStyleGenerator;
import com.vaadin.client.widget.grid.events.AbstractGridKeyEventHandler;
import com.vaadin.client.widget.grid.events.AbstractGridMouseEventHandler;
import com.vaadin.client.widget.grid.events.BodyClickHandler;
import com.vaadin.client.widget.grid.events.BodyDoubleClickHandler;
import com.vaadin.client.widget.grid.events.BodyKeyDownHandler;
import com.vaadin.client.widget.grid.events.BodyKeyPressHandler;
import com.vaadin.client.widget.grid.events.BodyKeyUpHandler;
import com.vaadin.client.widget.grid.events.FooterClickHandler;
import com.vaadin.client.widget.grid.events.FooterDoubleClickHandler;
import com.vaadin.client.widget.grid.events.FooterKeyDownHandler;
import com.vaadin.client.widget.grid.events.FooterKeyPressHandler;
import com.vaadin.client.widget.grid.events.FooterKeyUpHandler;
import com.vaadin.client.widget.grid.events.GridClickEvent;
import com.vaadin.client.widget.grid.events.GridDoubleClickEvent;
import com.vaadin.client.widget.grid.events.GridKeyDownEvent;
import com.vaadin.client.widget.grid.events.GridKeyPressEvent;
import com.vaadin.client.widget.grid.events.GridKeyUpEvent;
import com.vaadin.client.widget.grid.events.HeaderClickHandler;
import com.vaadin.client.widget.grid.events.HeaderDoubleClickHandler;
import com.vaadin.client.widget.grid.events.HeaderKeyDownHandler;
import com.vaadin.client.widget.grid.events.HeaderKeyPressHandler;
import com.vaadin.client.widget.grid.events.HeaderKeyUpHandler;
import com.vaadin.client.widget.grid.events.ScrollEvent;
import com.vaadin.client.widget.grid.events.ScrollHandler;
import com.vaadin.client.widget.grid.events.SelectAllEvent;
import com.vaadin.client.widget.grid.events.SelectAllHandler;
import com.vaadin.client.widget.grid.selection.HasSelectionHandlers;
import com.vaadin.client.widget.grid.selection.SelectionEvent;
import com.vaadin.client.widget.grid.selection.SelectionHandler;
import com.vaadin.client.widget.grid.selection.SelectionModel;
import com.vaadin.client.widget.grid.selection.SelectionModel.Multi;
import com.vaadin.client.widget.grid.selection.SelectionModelMulti;
import com.vaadin.client.widget.grid.selection.SelectionModelNone;
import com.vaadin.client.widget.grid.selection.SelectionModelSingle;
import com.vaadin.client.widget.grid.sort.Sort;
import com.vaadin.client.widget.grid.sort.SortEvent;
import com.vaadin.client.widget.grid.sort.SortHandler;
import com.vaadin.client.widget.grid.sort.SortOrder;
import com.vaadin.client.widgets.Escalator.AbstractRowContainer;
import com.vaadin.client.widgets.Grid.Editor.State;
import com.vaadin.shared.data.sort.SortDirection;
import com.vaadin.shared.ui.grid.GridConstants;
import com.vaadin.shared.ui.grid.GridStaticCellType;
import com.vaadin.shared.ui.grid.HeightMode;
import com.vaadin.shared.ui.grid.Range;
import com.vaadin.shared.ui.grid.ScrollDestination;
import com.vaadin.shared.util.SharedUtil;
/**
* A data grid view that supports columns and lazy loading of data rows from a
* data source.
*
* Columns
*
* Each column in Grid is represented by a {@link Column}. Each
* {@code GridColumn} has a custom implementation for
* {@link Column#getValue(Object)} that gets the row object as an argument, and
* returns the value for that particular column, extracted from the row object.
*
* Each column also has a Renderer. Its function is to take the value that is
* given by the {@code GridColumn} and display it to the user. A simple column
* might have a {@link com.vaadin.client.renderers.TextRenderer TextRenderer}
* that simply takes in a {@code String} and displays it as the cell's content.
* A more complex renderer might be
* {@link com.vaadin.client.renderers.ProgressBarRenderer ProgressBarRenderer}
* that takes in a floating point number, and displays a progress bar instead,
* based on the given number.
*
* See: {@link #addColumn(Column)}, {@link #addColumn(Column, int)} and
* {@link #addColumns(Column...)}. Also
* {@link Column#setRenderer(Renderer)}.
*
*
Data Sources
*
* Grid gets its data from a {@link DataSource}, providing row objects to Grid
* from a user-defined endpoint. It can be either a local in-memory data source
* (e.g. {@link com.vaadin.client.widget.grid.datasources.ListDataSource
* ListDataSource}) or even a remote one, retrieving data from e.g. a REST API
* (see {@link com.vaadin.client.data.AbstractRemoteDataSource
* AbstractRemoteDataSource}).
*
*
* @param
* The row type of the grid. The row type is the POJO type from where
* the data is retrieved into the column cells.
* @since 7.4
* @author Vaadin Ltd
*/
public class Grid extends ResizeComposite implements
HasSelectionHandlers, SubPartAware, DeferredWorker, HasWidgets,
HasEnabled {
/**
* Enum describing different sections of Grid.
*/
public enum Section {
HEADER, BODY, FOOTER
}
/**
* Abstract base class for Grid header and footer sections.
*
* @param
* the type of the rows in the section
*/
protected abstract static class StaticSection> {
/**
* A header or footer cell. Has a simple textual caption.
*
*/
static class StaticCell {
private Object content = null;
private int colspan = 1;
private StaticSection section;
private GridStaticCellType type = GridStaticCellType.TEXT;
private String styleName = null;
/**
* Sets the text displayed in this cell.
*
* @param text
* a plain text caption
*/
public void setText(String text) {
this.content = text;
this.type = GridStaticCellType.TEXT;
section.requestSectionRefresh();
}
/**
* Returns the text displayed in this cell.
*
* @return the plain text caption
*/
public String getText() {
if (type != GridStaticCellType.TEXT) {
throw new IllegalStateException(
"Cannot fetch Text from a cell with type " + type);
}
return (String) content;
}
protected StaticSection getSection() {
assert section != null;
return section;
}
protected void setSection(StaticSection section) {
this.section = section;
}
/**
* Returns the amount of columns the cell spans. By default is 1.
*
* @return The amount of columns the cell spans.
*/
public int getColspan() {
return colspan;
}
/**
* Sets the amount of columns the cell spans. Must be more or equal
* to 1. By default is 1.
*
* @param colspan
* the colspan to set
*/
public void setColspan(int colspan) {
if (colspan < 1) {
throw new IllegalArgumentException(
"Colspan cannot be less than 1");
}
this.colspan = colspan;
section.requestSectionRefresh();
}
/**
* Returns the html inside the cell.
*
* @throws IllegalStateException
* if trying to retrive HTML from a cell with a type
* other than {@link GridStaticCellType#HTML}.
* @return the html content of the cell.
*/
public String getHtml() {
if (type != GridStaticCellType.HTML) {
throw new IllegalStateException(
"Cannot fetch HTML from a cell with type " + type);
}
return (String) content;
}
/**
* Sets the content of the cell to the provided html. All previous
* content is discarded and the cell type is set to
* {@link GridStaticCellType#HTML}.
*
* @param html
* The html content of the cell
*/
public void setHtml(String html) {
this.content = html;
this.type = GridStaticCellType.HTML;
section.requestSectionRefresh();
}
/**
* Returns the widget in the cell.
*
* @throws IllegalStateException
* if the cell is not {@link GridStaticCellType#WIDGET}
*
* @return the widget in the cell
*/
public Widget getWidget() {
if (type != GridStaticCellType.WIDGET) {
throw new IllegalStateException(
"Cannot fetch Widget from a cell with type " + type);
}
return (Widget) content;
}
/**
* Set widget as the content of the cell. The type of the cell
* becomes {@link GridStaticCellType#WIDGET}. All previous content
* is discarded.
*
* @param widget
* The widget to add to the cell. Should not be
* previously attached anywhere (widget.getParent ==
* null).
*/
public void setWidget(Widget widget) {
this.content = widget;
this.type = GridStaticCellType.WIDGET;
section.requestSectionRefresh();
}
/**
* Returns the type of the cell.
*
* @return the type of content the cell contains.
*/
public GridStaticCellType getType() {
return type;
}
/**
* Returns the custom style name for this cell.
*
* @return the style name or null if no style name has been set
*/
public String getStyleName() {
return styleName;
}
/**
* Sets a custom style name for this cell.
*
* @param styleName
* the style name to set or null to not use any style
* name
*/
public void setStyleName(String styleName) {
this.styleName = styleName;
section.requestSectionRefresh();
}
}
/**
* Abstract base class for Grid header and footer rows.
*
* @param
* the type of the cells in the row
*/
abstract static class StaticRow {
private Map, CELLTYPE> cells = new HashMap, CELLTYPE>();
private StaticSection section;
/**
* Map from set of spanned columns to cell meta data.
*/
private Map>, CELLTYPE> cellGroups = new HashMap>, CELLTYPE>();
/**
* A custom style name for the row or null if none is set.
*/
private String styleName = null;
/**
* Returns the cell on given GridColumn. If the column is merged
* returned cell is the cell for the whole group.
*
* @param column
* the column in grid
* @return the cell on given column, merged cell for merged columns,
* null if not found
*/
public CELLTYPE getCell(Column column) {
Set> cellGroup = getCellGroupForColumn(column);
if (cellGroup != null) {
return cellGroups.get(cellGroup);
}
return cells.get(column);
}
/**
* Merges columns cells in a row
*
* @param columns
* the columns which header should be merged
* @return the remaining visible cell after the merge, or the cell
* on first column if all are hidden
*/
public CELLTYPE join(Column... columns) {
if (columns.length <= 1) {
throw new IllegalArgumentException(
"You can't merge less than 2 columns together.");
}
HashSet> columnGroup = new HashSet>();
for (Column column : columns) {
if (!cells.containsKey(column)) {
throw new IllegalArgumentException(
"Given column does not exists on row " + column);
} else if (getCellGroupForColumn(column) != null) {
throw new IllegalStateException(
"Column is already in a group.");
}
columnGroup.add(column);
}
CELLTYPE joinedCell = createCell();
cellGroups.put(columnGroup, joinedCell);
joinedCell.setSection(getSection());
calculateColspans();
return joinedCell;
}
/**
* Merges columns cells in a row
*
* @param cells
* The cells to merge. Must be from the same row.
* @return The remaining visible cell after the merge, or the first
* cell if all columns are hidden
*/
public CELLTYPE join(CELLTYPE... cells) {
if (cells.length <= 1) {
throw new IllegalArgumentException(
"You can't merge less than 2 cells together.");
}
Column[] columns = new Column[cells.length];
int j = 0;
for (Column column : this.cells.keySet()) {
CELLTYPE cell = this.cells.get(column);
if (!this.cells.containsValue(cells[j])) {
throw new IllegalArgumentException(
"Given cell does not exists on row");
} else if (cell.equals(cells[j])) {
columns[j++] = column;
if (j == cells.length) {
break;
}
}
}
return join(columns);
}
private Set> getCellGroupForColumn(Column column) {
for (Set> group : cellGroups.keySet()) {
if (group.contains(column)) {
return group;
}
}
return null;
}
void calculateColspans() {
// Reset all cells
for (CELLTYPE cell : this.cells.values()) {
cell.setColspan(1);
}
List> columnOrder = new ArrayList>(
section.grid.getColumns());
// Set colspan for grouped cells
for (Set> group : cellGroups.keySet()) {
if (!checkCellGroupAndOrder(columnOrder, group)) {
cellGroups.get(group).setColspan(1);
} else {
int colSpan = group.size();
cellGroups.get(group).setColspan(colSpan);
}
}
}
private boolean checkCellGroupAndOrder(
List> columnOrder, Set> cellGroup) {
if (!columnOrder.containsAll(cellGroup)) {
return false;
}
for (int i = 0; i < columnOrder.size(); ++i) {
if (!cellGroup.contains(columnOrder.get(i))) {
continue;
}
for (int j = 1; j < cellGroup.size(); ++j) {
if (!cellGroup.contains(columnOrder.get(i + j))) {
return false;
}
}
return true;
}
return false;
}
protected void addCell(Column column) {
CELLTYPE cell = createCell();
cell.setSection(getSection());
cells.put(column, cell);
}
protected void removeCell(Column column) {
cells.remove(column);
}
protected abstract CELLTYPE createCell();
protected StaticSection getSection() {
return section;
}
protected void setSection(StaticSection section) {
this.section = section;
}
/**
* Returns the custom style name for this row.
*
* @return the style name or null if no style name has been set
*/
public String getStyleName() {
return styleName;
}
/**
* Sets a custom style name for this row.
*
* @param styleName
* the style name to set or null to not use any style
* name
*/
public void setStyleName(String styleName) {
this.styleName = styleName;
section.requestSectionRefresh();
}
}
private Grid grid;
private List rows = new ArrayList();
private boolean visible = true;
/**
* Creates and returns a new instance of the row type.
*
* @return the created row
*/
protected abstract ROWTYPE createRow();
/**
* Informs the grid that this section should be re-rendered.
*
* Note that re-render means calling update() on each cell,
* preAttach()/postAttach()/preDetach()/postDetach() is not called as
* the cells are not removed from the DOM.
*/
protected abstract void requestSectionRefresh();
/**
* Sets the visibility of the whole section.
*
* @param visible
* true to show this section, false to hide
*/
public void setVisible(boolean visible) {
this.visible = visible;
requestSectionRefresh();
}
/**
* Returns the visibility of this section.
*
* @return true if visible, false otherwise.
*/
public boolean isVisible() {
return visible;
}
/**
* Inserts a new row at the given position. Shifts the row currently at
* that position and any subsequent rows down (adds one to their
* indices).
*
* @param index
* the position at which to insert the row
* @return the new row
*
* @throws IndexOutOfBoundsException
* if the index is out of bounds
* @see #appendRow()
* @see #prependRow()
* @see #removeRow(int)
* @see #removeRow(StaticRow)
*/
public ROWTYPE addRowAt(int index) {
ROWTYPE row = createRow();
row.setSection(this);
for (int i = 0; i < getGrid().getColumnCount(); ++i) {
row.addCell(grid.getColumn(i));
}
rows.add(index, row);
requestSectionRefresh();
return row;
}
/**
* Adds a new row at the top of this section.
*
* @return the new row
* @see #appendRow()
* @see #addRowAt(int)
* @see #removeRow(int)
* @see #removeRow(StaticRow)
*/
public ROWTYPE prependRow() {
return addRowAt(0);
}
/**
* Adds a new row at the bottom of this section.
*
* @return the new row
* @see #prependRow()
* @see #addRowAt(int)
* @see #removeRow(int)
* @see #removeRow(StaticRow)
*/
public ROWTYPE appendRow() {
return addRowAt(rows.size());
}
/**
* Removes the row at the given position.
*
* @param index
* the position of the row
*
* @throws IndexOutOfBoundsException
* if the index is out of bounds
* @see #addRowAt(int)
* @see #appendRow()
* @see #prependRow()
* @see #removeRow(StaticRow)
*/
public void removeRow(int index) {
rows.remove(index);
requestSectionRefresh();
}
/**
* Removes the given row from the section.
*
* @param row
* the row to be removed
*
* @throws IllegalArgumentException
* if the row does not exist in this section
* @see #addRowAt(int)
* @see #appendRow()
* @see #prependRow()
* @see #removeRow(int)
*/
public void removeRow(ROWTYPE row) {
try {
removeRow(rows.indexOf(row));
} catch (IndexOutOfBoundsException e) {
throw new IllegalArgumentException(
"Section does not contain the given row");
}
}
/**
* Returns the row at the given position.
*
* @param index
* the position of the row
* @return the row with the given index
*
* @throws IndexOutOfBoundsException
* if the index is out of bounds
*/
public ROWTYPE getRow(int index) {
try {
return rows.get(index);
} catch (IndexOutOfBoundsException e) {
throw new IllegalArgumentException("Row with index " + index
+ " does not exist");
}
}
/**
* Returns the number of rows in this section.
*
* @return the number of rows
*/
public int getRowCount() {
return rows.size();
}
protected List getRows() {
return rows;
}
protected int getVisibleRowCount() {
return isVisible() ? getRowCount() : 0;
}
protected void addColumn(Column column) {
for (ROWTYPE row : rows) {
row.addCell(column);
}
}
protected void removeColumn(Column column) {
for (ROWTYPE row : rows) {
row.removeCell(column);
}
}
protected void setGrid(Grid grid) {
this.grid = grid;
}
protected Grid getGrid() {
assert grid != null;
return grid;
}
}
/**
* Represents the header section of a Grid. A header consists of a single
* header row containing a header cell for each column. Each cell has a
* simple textual caption.
*/
protected static class Header extends StaticSection {
private HeaderRow defaultRow;
private boolean markAsDirty = false;
@Override
public void removeRow(int index) {
HeaderRow removedRow = getRow(index);
super.removeRow(index);
if (removedRow == defaultRow) {
setDefaultRow(null);
}
}
/**
* Sets the default row of this header. The default row is a special
* header row providing a user interface for sorting columns.
*
* @param row
* the new default row, or null for no default row
*
* @throws IllegalArgumentException
* this header does not contain the row
*/
public void setDefaultRow(HeaderRow row) {
if (row == defaultRow) {
return;
}
if (row != null && !getRows().contains(row)) {
throw new IllegalArgumentException(
"Cannot set a default row that does not exist in the container");
}
if (defaultRow != null) {
defaultRow.setDefault(false);
}
if (row != null) {
row.setDefault(true);
}
defaultRow = row;
requestSectionRefresh();
}
/**
* Returns the current default row of this header. The default row is a
* special header row providing a user interface for sorting columns.
*
* @return the default row or null if no default row set
*/
public HeaderRow getDefaultRow() {
return defaultRow;
}
@Override
protected HeaderRow createRow() {
return new HeaderRow();
}
@Override
protected void requestSectionRefresh() {
markAsDirty = true;
/*
* Defer the refresh so if we multiple times call refreshSection()
* (for example when updating cell values) we only get one actual
* refresh in the end.
*/
Scheduler.get().scheduleFinally(new Scheduler.ScheduledCommand() {
@Override
public void execute() {
if (markAsDirty) {
markAsDirty = false;
getGrid().refreshHeader();
}
}
});
}
/**
* Returns the events consumed by the header
*
* @return a collection of BrowserEvents
*/
public Collection getConsumedEvents() {
return Arrays.asList(BrowserEvents.TOUCHSTART,
BrowserEvents.TOUCHMOVE, BrowserEvents.TOUCHEND,
BrowserEvents.TOUCHCANCEL, BrowserEvents.CLICK);
}
}
/**
* A single row in a grid header section.
*
*/
public static class HeaderRow extends StaticSection.StaticRow {
private boolean isDefault = false;
protected void setDefault(boolean isDefault) {
this.isDefault = isDefault;
}
public boolean isDefault() {
return isDefault;
}
@Override
protected HeaderCell createCell() {
return new HeaderCell();
}
}
/**
* A single cell in a grid header row. Has a textual caption.
*
*/
public static class HeaderCell extends StaticSection.StaticCell {
}
/**
* Represents the footer section of a Grid. The footer is always empty.
*/
protected static class Footer extends StaticSection {
private boolean markAsDirty = false;
@Override
protected FooterRow createRow() {
return new FooterRow();
}
@Override
protected void requestSectionRefresh() {
markAsDirty = true;
/*
* Defer the refresh so if we multiple times call refreshSection()
* (for example when updating cell values) we only get one actual
* refresh in the end.
*/
Scheduler.get().scheduleFinally(new Scheduler.ScheduledCommand() {
@Override
public void execute() {
if (markAsDirty) {
markAsDirty = false;
getGrid().refreshFooter();
}
}
});
}
}
/**
* A single cell in a grid Footer row. Has a textual caption.
*
*/
public static class FooterCell extends StaticSection.StaticCell {
}
/**
* A single row in a grid Footer section.
*
*/
public static class FooterRow extends StaticSection.StaticRow {
@Override
protected FooterCell createCell() {
return new FooterCell();
}
}
private static class EditorRequestImpl implements EditorRequest {
/**
* A callback interface used to notify the invoker of the editor handler
* of completed editor requests.
*
* @param
* the row data type
*/
public static interface RequestCallback {
/**
* The method that must be called when the request has been
* processed correctly.
*
* @param request
* the original request object
*/
public void onSuccess(EditorRequest request);
/**
* The method that must be called when processing the request has
* produced an aborting error.
*
* @param request
* the original request object
*/
public void onError(EditorRequest request);
}
private Grid grid;
private int rowIndex;
private RequestCallback callback;
private boolean completed = false;
public EditorRequestImpl(Grid grid, int rowIndex,
RequestCallback callback) {
this.grid = grid;
this.rowIndex = rowIndex;
this.callback = callback;
}
@Override
public int getRowIndex() {
return rowIndex;
}
@Override
public T getRow() {
return grid.getDataSource().getRow(rowIndex);
}
@Override
public Grid getGrid() {
return grid;
}
@Override
public Widget getWidget(Grid.Column column) {
Widget w = grid.getEditorWidget(column);
assert w != null;
return w;
}
private void complete(String errorMessage,
Collection> errorColumns) {
if (completed) {
throw new IllegalStateException(
"An EditorRequest must be completed exactly once");
}
completed = true;
grid.getEditor().setErrorMessage(errorMessage);
grid.getEditor().clearEditorColumnErrors();
if (errorColumns != null) {
for (Column column : errorColumns) {
grid.getEditor().setEditorColumnError(column, true);
}
}
}
@Override
public void success() {
complete(null, null);
if (callback != null) {
callback.onSuccess(this);
}
}
@Override
public void failure(String errorMessage,
Collection> errorColumns) {
complete(errorMessage, errorColumns);
if (callback != null) {
callback.onError(this);
}
}
@Override
public boolean isCompleted() {
return completed;
}
}
/**
* An editor UI for Grid rows. A single Grid row at a time can be opened for
* editing.
*/
protected static class Editor {
public static final int KEYCODE_SHOW = KeyCodes.KEY_ENTER;
public static final int KEYCODE_HIDE = KeyCodes.KEY_ESCAPE;
private static final String ERROR_CLASS_NAME = "error";
protected enum State {
INACTIVE, ACTIVATING, BINDING, ACTIVE, SAVING
}
private Grid grid;
private EditorHandler handler;
private DivElement editorOverlay = DivElement.as(DOM.createDiv());
private DivElement cellWrapper = DivElement.as(DOM.createDiv());
private DivElement messageAndButtonsWrapper = DivElement.as(DOM
.createDiv());
private DivElement messageWrapper = DivElement.as(DOM.createDiv());
private DivElement buttonsWrapper = DivElement.as(DOM.createDiv());
// Element which contains the error message for the editor
// Should only be added to the DOM when there's a message to show
private DivElement message = DivElement.as(DOM.createDiv());
private Map, Widget> columnToWidget = new HashMap, Widget>();
private boolean enabled = false;
private State state = State.INACTIVE;
private int rowIndex = -1;
private String styleName = null;
private HandlerRegistration scrollHandler;
private final Button saveButton;
private final Button cancelButton;
private static final int SAVE_TIMEOUT_MS = 5000;
private final Timer saveTimeout = new Timer() {
@Override
public void run() {
getLogger().warning(
"Editor save action is taking longer than expected ("
+ SAVE_TIMEOUT_MS + "ms). Does your "
+ EditorHandler.class.getSimpleName()
+ " remember to call success() or fail()?");
}
};
private final EditorRequestImpl.RequestCallback saveRequestCallback = new EditorRequestImpl.RequestCallback() {
@Override
public void onSuccess(EditorRequest request) {
if (state == State.SAVING) {
cleanup();
cancel();
}
}
@Override
public void onError(EditorRequest request) {
if (state == State.SAVING) {
cleanup();
// TODO probably not the most correct thing to do...
getLogger().warning(
"An error occurred when trying to save the "
+ "modified row");
}
}
private void cleanup() {
state = State.ACTIVE;
setButtonsEnabled(true);
saveTimeout.cancel();
}
};
private static final int BIND_TIMEOUT_MS = 5000;
private final Timer bindTimeout = new Timer() {
@Override
public void run() {
getLogger().warning(
"Editor bind action is taking longer than expected ("
+ BIND_TIMEOUT_MS + "ms). Does your "
+ EditorHandler.class.getSimpleName()
+ " remember to call success() or fail()?");
}
};
private final EditorRequestImpl.RequestCallback bindRequestCallback = new EditorRequestImpl.RequestCallback() {
@Override
public void onSuccess(EditorRequest request) {
if (state == State.BINDING) {
state = State.ACTIVE;
bindTimeout.cancel();
showOverlay(grid.getEscalator().getBody()
.getRowElement(request.getRowIndex()));
}
}
@Override
public void onError(EditorRequest request) {
if (state == State.BINDING) {
state = State.INACTIVE;
bindTimeout.cancel();
// TODO show something in the DOM as well?
getLogger().warning(
"An error occurred while trying to show the "
+ "Grid editor");
grid.getEscalator().setScrollLocked(Direction.VERTICAL,
false);
updateSelectionCheckboxesAsNeeded(true);
}
}
};
/** A set of all the columns that display an error flag. */
private final Set> columnErrors = new HashSet>();
public Editor() {
saveButton = new Button();
saveButton.setText(GridConstants.DEFAULT_SAVE_CAPTION);
saveButton.addClickHandler(new ClickHandler() {
@Override
public void onClick(ClickEvent event) {
save();
}
});
cancelButton = new Button();
cancelButton.setText(GridConstants.DEFAULT_CANCEL_CAPTION);
cancelButton.addClickHandler(new ClickHandler() {
@Override
public void onClick(ClickEvent event) {
cancel();
}
});
}
public void setErrorMessage(String errorMessage) {
if (errorMessage == null) {
message.removeFromParent();
} else {
message.setInnerText(errorMessage);
if (message.getParentElement() == null) {
messageWrapper.appendChild(message);
}
}
}
public int getRow() {
return rowIndex;
}
/**
* Opens the editor over the row with the given index.
*
* @param rowIndex
* the index of the row to be edited
*
* @throws IllegalStateException
* if this editor is not enabled
* @throws IllegalStateException
* if this editor is already in edit mode
*/
public void editRow(int rowIndex) {
if (!enabled) {
throw new IllegalStateException(
"Cannot edit row: editor is not enabled");
}
if (state != State.INACTIVE) {
throw new IllegalStateException(
"Cannot edit row: editor already in edit mode");
}
this.rowIndex = rowIndex;
state = State.ACTIVATING;
if (grid.getEscalator().getVisibleRowRange().contains(rowIndex)) {
show();
} else {
grid.scrollToRow(rowIndex, ScrollDestination.MIDDLE);
}
}
/**
* Cancels the currently active edit and hides the editor. Any changes
* that are not {@link #save() saved} are lost.
*
* @throws IllegalStateException
* if this editor is not enabled
* @throws IllegalStateException
* if this editor is not in edit mode
*/
public void cancel() {
if (!enabled) {
throw new IllegalStateException(
"Cannot cancel edit: editor is not enabled");
}
if (state == State.INACTIVE) {
throw new IllegalStateException(
"Cannot cancel edit: editor is not in edit mode");
}
hideOverlay();
grid.getEscalator().setScrollLocked(Direction.VERTICAL, false);
EditorRequest request = new EditorRequestImpl(grid, rowIndex,
null);
handler.cancel(request);
state = State.INACTIVE;
updateSelectionCheckboxesAsNeeded(true);
}
private void updateSelectionCheckboxesAsNeeded(boolean isEnabled) {
if (grid.getSelectionModel() instanceof Multi) {
grid.refreshBody();
CheckBox checkBox = (CheckBox) grid.getDefaultHeaderRow()
.getCell(grid.selectionColumn).getWidget();
checkBox.setEnabled(isEnabled);
}
}
/**
* Saves any unsaved changes to the data source and hides the editor.
*
* @throws IllegalStateException
* if this editor is not enabled
* @throws IllegalStateException
* if this editor is not in edit mode
*/
public void save() {
if (!enabled) {
throw new IllegalStateException(
"Cannot save: editor is not enabled");
}
if (state != State.ACTIVE) {
throw new IllegalStateException(
"Cannot save: editor is not in edit mode");
}
state = State.SAVING;
setButtonsEnabled(false);
saveTimeout.schedule(SAVE_TIMEOUT_MS);
EditorRequest request = new EditorRequestImpl(grid, rowIndex,
saveRequestCallback);
handler.save(request);
updateSelectionCheckboxesAsNeeded(true);
}
/**
* Returns the handler responsible for binding data and editor widgets
* to this editor.
*
* @return the editor handler or null if not set
*/
public EditorHandler getHandler() {
return handler;
}
/**
* Sets the handler responsible for binding data and editor widgets to
* this editor.
*
* @param rowHandler
* the new editor handler
*
* @throws IllegalStateException
* if this editor is currently in edit mode
*/
public void setHandler(EditorHandler rowHandler) {
if (state != State.INACTIVE) {
throw new IllegalStateException(
"Cannot set EditorHandler: editor is currently in edit mode");
}
handler = rowHandler;
}
public boolean isEnabled() {
return enabled;
}
/**
* Sets the enabled state of this editor.
*
* @param enabled
* true if enabled, false otherwise
*
* @throws IllegalStateException
* if in edit mode and trying to disable
* @throws IllegalStateException
* if the editor handler is not set
*/
public void setEnabled(boolean enabled) {
if (enabled == false && state != State.INACTIVE) {
throw new IllegalStateException(
"Cannot disable: editor is in edit mode");
} else if (enabled == true && getHandler() == null) {
throw new IllegalStateException(
"Cannot enable: EditorHandler not set");
}
this.enabled = enabled;
}
protected void show() {
if (state == State.ACTIVATING) {
state = State.BINDING;
bindTimeout.schedule(BIND_TIMEOUT_MS);
EditorRequest request = new EditorRequestImpl(grid,
rowIndex, bindRequestCallback);
handler.bind(request);
grid.getEscalator().setScrollLocked(Direction.VERTICAL, true);
updateSelectionCheckboxesAsNeeded(false);
}
}
protected void setGrid(final Grid grid) {
assert grid != null : "Grid cannot be null";
assert this.grid == null : "Can only attach editor to Grid once";
this.grid = grid;
grid.addDataAvailableHandler(new DataAvailableHandler() {
@Override
public void onDataAvailable(DataAvailableEvent event) {
if (event.getAvailableRows().contains(rowIndex)) {
show();
}
}
});
}
protected State getState() {
return state;
}
protected void setState(State state) {
this.state = state;
}
/**
* Returns the editor widget associated with the given column. If the
* editor is not active or the column is not
* {@link Grid.Column#isEditable() editable}, returns null.
*
* @param column
* the column
* @return the widget if the editor is open and the column is editable,
* null otherwise
*/
protected Widget getWidget(Column column) {
return columnToWidget.get(column);
}
/**
* Opens the editor overlay over the given table row.
*
* @param tr
* the row to be edited
*/
protected void showOverlay(TableRowElement tr) {
DivElement gridElement = DivElement.as(grid.getElement());
scrollHandler = grid.addScrollHandler(new ScrollHandler() {
@Override
public void onScroll(ScrollEvent event) {
updateHorizontalScrollPosition();
}
});
gridElement.appendChild(editorOverlay);
editorOverlay.appendChild(cellWrapper);
editorOverlay.appendChild(messageAndButtonsWrapper);
for (int i = 0; i < tr.getCells().getLength(); i++) {
Element cell = createCell(tr.getCells().getItem(i));
cellWrapper.appendChild(cell);
Column column = grid.getColumn(i);
if (column.isEditable()) {
Widget editor = getHandler().getWidget(column);
if (editor != null) {
columnToWidget.put(column, editor);
attachWidget(editor, cell);
}
}
}
// Only add these elements once
if (!messageAndButtonsWrapper.isOrHasChild(messageWrapper)) {
messageAndButtonsWrapper.appendChild(messageWrapper);
messageAndButtonsWrapper.appendChild(buttonsWrapper);
}
attachWidget(saveButton, buttonsWrapper);
attachWidget(cancelButton, buttonsWrapper);
updateHorizontalScrollPosition();
AbstractRowContainer body = (AbstractRowContainer) grid
.getEscalator().getBody();
double rowTop = body.getRowTop(tr);
int bodyTop = body.getElement().getAbsoluteTop();
int gridTop = gridElement.getAbsoluteTop();
double overlayTop = rowTop + bodyTop - gridTop;
if (buttonsShouldBeRenderedBelow(tr)) {
// Default case, editor buttons are below the edited row
editorOverlay.getStyle().setTop(overlayTop, Unit.PX);
editorOverlay.getStyle().clearBottom();
} else {
// Move message and buttons wrapper on top of cell wrapper if
// there is not enough space visible space under and fix the
// overlay from the bottom
editorOverlay.appendChild(cellWrapper);
int gridHeight = grid.getElement().getOffsetHeight();
editorOverlay.getStyle()
.setBottom(
gridHeight - overlayTop - tr.getOffsetHeight(),
Unit.PX);
editorOverlay.getStyle().clearTop();
}
// Do not render over the vertical scrollbar
int nativeScrollbarSize = WidgetUtil.getNativeScrollbarSize();
if (nativeScrollbarSize > 0) {
editorOverlay.getStyle().setRight(nativeScrollbarSize, Unit.PX);
}
}
private boolean buttonsShouldBeRenderedBelow(TableRowElement tr) {
TableSectionElement tfoot = grid.escalator.getFooter().getElement();
double tfootPageTop = WidgetUtil.getBoundingClientRect(tfoot)
.getTop();
double trPageBottom = WidgetUtil.getBoundingClientRect(tr)
.getBottom();
int messageAndButtonsHeight = messageAndButtonsWrapper
.getOffsetHeight();
double bottomOfButtons = trPageBottom + messageAndButtonsHeight;
return bottomOfButtons < tfootPageTop;
}
protected void hideOverlay() {
for (Widget w : columnToWidget.values()) {
setParent(w, null);
}
columnToWidget.clear();
detachWidget(saveButton);
detachWidget(cancelButton);
editorOverlay.removeAllChildren();
cellWrapper.removeAllChildren();
editorOverlay.removeFromParent();
scrollHandler.removeHandler();
clearEditorColumnErrors();
}
protected void setStylePrimaryName(String primaryName) {
if (styleName != null) {
editorOverlay.removeClassName(styleName);
cellWrapper.removeClassName(styleName + "-cells");
messageAndButtonsWrapper.removeClassName(styleName + "-footer");
messageWrapper.removeClassName(styleName + "-message");
buttonsWrapper.removeClassName(styleName + "-buttons");
saveButton.removeStyleName(styleName + "-save");
cancelButton.removeStyleName(styleName + "-cancel");
}
styleName = primaryName + "-editor";
editorOverlay.setClassName(styleName);
cellWrapper.setClassName(styleName + "-cells");
messageAndButtonsWrapper.setClassName(styleName + "-footer");
messageWrapper.setClassName(styleName + "-message");
buttonsWrapper.setClassName(styleName + "-buttons");
saveButton.setStyleName(styleName + "-save");
cancelButton.setStyleName(styleName + "-cancel");
}
/**
* Creates an editor cell corresponding to the given table cell. The
* returned element is empty and has the same dimensions and position as
* the table cell.
*
* @param td
* the table cell used as a reference
* @return an editor cell corresponding to the given cell
*/
protected Element createCell(TableCellElement td) {
DivElement cell = DivElement.as(DOM.createDiv());
double width = WidgetUtil
.getRequiredWidthBoundingClientRectDouble(td);
double height = WidgetUtil
.getRequiredHeightBoundingClientRectDouble(td);
setBounds(cell, td.getOffsetLeft(), td.getOffsetTop(), width,
height);
return cell;
}
private void attachWidget(Widget w, Element parent) {
parent.appendChild(w.getElement());
setParent(w, grid);
}
private void detachWidget(Widget w) {
setParent(w, null);
w.getElement().removeFromParent();
}
private static void setBounds(Element e, double left, double top,
double width, double height) {
Style style = e.getStyle();
style.setLeft(left, Unit.PX);
style.setTop(top, Unit.PX);
style.setWidth(width, Unit.PX);
style.setHeight(height, Unit.PX);
}
private void updateHorizontalScrollPosition() {
double scrollLeft = grid.getScrollLeft();
cellWrapper.getStyle().setLeft(-scrollLeft, Unit.PX);
}
protected void setGridEnabled(boolean enabled) {
// TODO: This should be informed to handler as well so possible
// fields can be disabled.
setButtonsEnabled(enabled);
}
private void setButtonsEnabled(boolean enabled) {
saveButton.setEnabled(enabled);
cancelButton.setEnabled(enabled);
}
public void setSaveCaption(String saveCaption)
throws IllegalArgumentException {
if (saveCaption == null) {
throw new IllegalArgumentException(
"Save caption cannot be null");
}
saveButton.setText(saveCaption);
}
public String getSaveCaption() {
return saveButton.getText();
}
public void setCancelCaption(String cancelCaption)
throws IllegalArgumentException {
if (cancelCaption == null) {
throw new IllegalArgumentException(
"Cancel caption cannot be null");
}
cancelButton.setText(cancelCaption);
}
public String getCancelCaption() {
return cancelButton.getText();
}
public void setEditorColumnError(Column column, boolean hasError) {
if (state != State.ACTIVE && state != State.SAVING) {
throw new IllegalStateException("Cannot set cell error "
+ "status: editor is neither active nor saving.");
}
if (isEditorColumnError(column) == hasError) {
return;
}
Element editorCell = getWidget(column).getElement()
.getParentElement();
if (hasError) {
editorCell.addClassName(ERROR_CLASS_NAME);
columnErrors.add(column);
} else {
editorCell.removeClassName(ERROR_CLASS_NAME);
columnErrors.remove(column);
}
}
public void clearEditorColumnErrors() {
/*
* editorOverlay has no children if it's not active, effectively
* making this loop a NOOP.
*/
Element e = editorOverlay.getFirstChildElement();
while (e != null) {
e.removeClassName(ERROR_CLASS_NAME);
e = e.getNextSiblingElement();
}
columnErrors.clear();
}
public boolean isEditorColumnError(Column column) {
return columnErrors.contains(column);
}
}
public static abstract class AbstractGridKeyEvent
extends KeyEvent {
private Grid grid;
private final Type associatedType = new Type(
getBrowserEventType(), this);
private final CellReference targetCell;
public AbstractGridKeyEvent(Grid grid, CellReference targetCell) {
this.grid = grid;
this.targetCell = targetCell;
}
protected abstract String getBrowserEventType();
/**
* Gets the Grid instance for this event.
*
* @return grid
*/
public Grid getGrid() {
return grid;
}
/**
* Gets the focused cell for this event.
*
* @return focused cell
*/
public CellReference getFocusedCell() {
return targetCell;
}
@Override
protected void dispatch(HANDLER handler) {
EventTarget target = getNativeEvent().getEventTarget();
if (Element.is(target)
&& !grid.isElementInChildWidget(Element.as(target))) {
Section section = Section.FOOTER;
final RowContainer container = grid.cellFocusHandler.containerWithFocus;
if (container == grid.escalator.getHeader()) {
section = Section.HEADER;
} else if (container == grid.escalator.getBody()) {
section = Section.BODY;
}
doDispatch(handler, section);
}
}
protected abstract void doDispatch(HANDLER handler, Section section);
@Override
public Type getAssociatedType() {
return associatedType;
}
}
public static abstract class AbstractGridMouseEvent
extends MouseEvent {
private Grid grid;
private final CellReference targetCell;
private final Type associatedType = new Type(
getBrowserEventType(), this);
public AbstractGridMouseEvent(Grid grid, CellReference targetCell) {
this.grid = grid;
this.targetCell = targetCell;
}
protected abstract String getBrowserEventType();
/**
* Gets the Grid instance for this event.
*
* @return grid
*/
public Grid getGrid() {
return grid;
}
/**
* Gets the reference of target cell for this event.
*
* @return target cell
*/
public CellReference getTargetCell() {
return targetCell;
}
@Override
protected void dispatch(HANDLER handler) {
EventTarget target = getNativeEvent().getEventTarget();
if (!Element.is(target)) {
// Target is not an element
return;
}
Element targetElement = Element.as(target);
if (grid.isElementInChildWidget(targetElement)) {
// Target is some widget inside of Grid
return;
}
final RowContainer container = grid.escalator
.findRowContainer(targetElement);
if (container == null) {
// No container for given element
return;
}
Section section = Section.FOOTER;
if (container == grid.escalator.getHeader()) {
section = Section.HEADER;
} else if (container == grid.escalator.getBody()) {
section = Section.BODY;
}
doDispatch(handler, section);
}
protected abstract void doDispatch(HANDLER handler, Section section);
@Override
public Type getAssociatedType() {
return associatedType;
}
}
private static final String CUSTOM_STYLE_PROPERTY_NAME = "customStyle";
private EventCellReference eventCell = new EventCellReference(this);
private GridKeyDownEvent keyDown = new GridKeyDownEvent(this, eventCell);
private GridKeyUpEvent keyUp = new GridKeyUpEvent(this, eventCell);
private GridKeyPressEvent keyPress = new GridKeyPressEvent(this, eventCell);
private GridClickEvent clickEvent = new GridClickEvent(this, eventCell);
private GridDoubleClickEvent doubleClickEvent = new GridDoubleClickEvent(
this, eventCell);
private class CellFocusHandler {
private RowContainer containerWithFocus = escalator.getBody();
private int rowWithFocus = 0;
private Range cellFocusRange = Range.withLength(0, 1);
private int lastFocusedBodyRow = 0;
private int lastFocusedHeaderRow = 0;
private int lastFocusedFooterRow = 0;
private TableCellElement cellWithFocusStyle = null;
private TableRowElement rowWithFocusStyle = null;
public CellFocusHandler() {
sinkEvents(getNavigationEvents());
}
private Cell getFocusedCell() {
return new Cell(rowWithFocus, cellFocusRange.getStart(),
cellWithFocusStyle);
}
/**
* Sets style names for given cell when needed.
*/
public void updateFocusedCellStyle(FlyweightCell cell,
RowContainer cellContainer) {
int cellRow = cell.getRow();
int cellColumn = cell.getColumn();
int colSpan = cell.getColSpan();
boolean columnHasFocus = Range.withLength(cellColumn, colSpan)
.intersects(cellFocusRange);
if (cellContainer == containerWithFocus) {
// Cell is in the current container
if (cellRow == rowWithFocus && columnHasFocus) {
if (cellWithFocusStyle != cell.getElement()) {
// Cell is correct but it does not have focused style
if (cellWithFocusStyle != null) {
// Remove old focus style
setStyleName(cellWithFocusStyle,
cellFocusStyleName, false);
}
cellWithFocusStyle = cell.getElement();
// Add focus style to correct cell.
setStyleName(cellWithFocusStyle, cellFocusStyleName,
true);
}
} else if (cellWithFocusStyle == cell.getElement()) {
// Due to escalator reusing cells, a new cell has the same
// element but is not the focused cell.
setStyleName(cellWithFocusStyle, cellFocusStyleName, false);
cellWithFocusStyle = null;
}
}
}
/**
* Sets focus style for the given row if needed.
*
* @param row
* a row object
*/
public void updateFocusedRowStyle(Row row) {
if (rowWithFocus == row.getRow()
&& containerWithFocus == escalator.getBody()) {
if (row.getElement() != rowWithFocusStyle) {
// Row should have focus style but does not have it.
if (rowWithFocusStyle != null) {
setStyleName(rowWithFocusStyle, rowFocusStyleName,
false);
}
rowWithFocusStyle = row.getElement();
setStyleName(rowWithFocusStyle, rowFocusStyleName, true);
}
} else if (rowWithFocusStyle == row.getElement()
|| (containerWithFocus != escalator.getBody() && rowWithFocusStyle != null)) {
// Remove focus style.
setStyleName(rowWithFocusStyle, rowFocusStyleName, false);
rowWithFocusStyle = null;
}
}
/**
* Sets the currently focused.
*
* @param row
* the index of the row having focus
* @param column
* the index of the column having focus
* @param container
* the row container having focus
*/
private void setCellFocus(int row, int column, RowContainer container) {
if (row == rowWithFocus && cellFocusRange.contains(column)
&& container == this.containerWithFocus) {
refreshRow(rowWithFocus);
return;
}
int oldRow = rowWithFocus;
rowWithFocus = row;
Range oldRange = cellFocusRange;
if (container == escalator.getBody()) {
scrollToRow(rowWithFocus);
cellFocusRange = Range.withLength(column, 1);
} else {
int i = 0;
Element cell = container.getRowElement(rowWithFocus)
.getFirstChildElement();
do {
int colSpan = cell
.getPropertyInt(FlyweightCell.COLSPAN_ATTR);
Range cellRange = Range.withLength(i, colSpan);
if (cellRange.contains(column)) {
cellFocusRange = cellRange;
break;
}
cell = cell.getNextSiblingElement();
++i;
} while (cell != null);
}
if (column >= escalator.getColumnConfiguration()
.getFrozenColumnCount()) {
escalator.scrollToColumn(column, ScrollDestination.ANY, 10);
}
if (this.containerWithFocus == container) {
if (oldRange.equals(cellFocusRange) && oldRow != rowWithFocus) {
refreshRow(oldRow);
} else {
refreshHeader();
refreshFooter();
}
} else {
RowContainer oldContainer = this.containerWithFocus;
this.containerWithFocus = container;
if (oldContainer == escalator.getBody()) {
lastFocusedBodyRow = oldRow;
} else if (oldContainer == escalator.getHeader()) {
lastFocusedHeaderRow = oldRow;
} else {
lastFocusedFooterRow = oldRow;
}
if (!oldRange.equals(cellFocusRange)) {
refreshHeader();
refreshFooter();
if (oldContainer == escalator.getBody()) {
oldContainer.refreshRows(oldRow, 1);
}
} else {
oldContainer.refreshRows(oldRow, 1);
}
}
refreshRow(rowWithFocus);
}
/**
* Sets focus on a cell.
*
*
* Note: cell focus is not the same as JavaScript's
* {@code document.activeElement}.
*
* @param cell
* a cell object
*/
public void setCellFocus(CellReference cell) {
setCellFocus(cell.getRowIndex(), cell.getColumnIndex(),
escalator.findRowContainer(cell.getElement()));
}
/**
* Gets list of events that can be used for cell focusing.
*
* @return list of navigation related event types
*/
public Collection getNavigationEvents() {
return Arrays.asList(BrowserEvents.KEYDOWN, BrowserEvents.CLICK);
}
/**
* Handle events that can move the cell focus.
*/
public void handleNavigationEvent(Event event, CellReference cell) {
if (event.getType().equals(BrowserEvents.CLICK)) {
setCellFocus(cell);
// Grid should have focus when clicked.
getElement().focus();
} else if (event.getType().equals(BrowserEvents.KEYDOWN)) {
int newRow = rowWithFocus;
RowContainer newContainer = containerWithFocus;
int newColumn = cellFocusRange.getStart();
switch (event.getKeyCode()) {
case KeyCodes.KEY_DOWN:
++newRow;
break;
case KeyCodes.KEY_UP:
--newRow;
break;
case KeyCodes.KEY_RIGHT:
if (cellFocusRange.getEnd() >= getColumns().size()) {
return;
}
newColumn = cellFocusRange.getEnd();
break;
case KeyCodes.KEY_LEFT:
if (newColumn == 0) {
return;
}
--newColumn;
break;
case KeyCodes.KEY_TAB:
if (event.getShiftKey()) {
newContainer = getPreviousContainer(containerWithFocus);
} else {
newContainer = getNextContainer(containerWithFocus);
}
if (newContainer == containerWithFocus) {
return;
}
break;
default:
return;
}
if (newContainer != containerWithFocus) {
if (newContainer == escalator.getBody()) {
newRow = lastFocusedBodyRow;
} else if (newContainer == escalator.getHeader()) {
newRow = lastFocusedHeaderRow;
} else {
newRow = lastFocusedFooterRow;
}
} else if (newRow < 0) {
newContainer = getPreviousContainer(newContainer);
if (newContainer == containerWithFocus) {
newRow = 0;
} else if (newContainer == escalator.getBody()) {
newRow = getLastVisibleRowIndex();
} else {
newRow = newContainer.getRowCount() - 1;
}
} else if (newRow >= containerWithFocus.getRowCount()) {
newContainer = getNextContainer(newContainer);
if (newContainer == containerWithFocus) {
newRow = containerWithFocus.getRowCount() - 1;
} else if (newContainer == escalator.getBody()) {
newRow = getFirstVisibleRowIndex();
} else {
newRow = 0;
}
}
if (newContainer.getRowCount() == 0) {
/*
* There are no rows in the container. Can't change the
* focused cell.
*/
return;
}
event.preventDefault();
event.stopPropagation();
setCellFocus(newRow, newColumn, newContainer);
}
}
private RowContainer getPreviousContainer(RowContainer current) {
if (current == escalator.getFooter()) {
current = escalator.getBody();
} else if (current == escalator.getBody()) {
current = escalator.getHeader();
} else {
return current;
}
if (current.getRowCount() == 0) {
return getPreviousContainer(current);
}
return current;
}
private RowContainer getNextContainer(RowContainer current) {
if (current == escalator.getHeader()) {
current = escalator.getBody();
} else if (current == escalator.getBody()) {
current = escalator.getFooter();
} else {
return current;
}
if (current.getRowCount() == 0) {
return getNextContainer(current);
}
return current;
}
private void refreshRow(int row) {
containerWithFocus.refreshRows(row, 1);
}
/**
* Offsets the focused cell's range.
*
* @param offset
* offset for fixing focused cell's range
*/
public void offsetRangeBy(int offset) {
cellFocusRange = cellFocusRange.offsetBy(offset);
}
/**
* Informs {@link CellFocusHandler} that certain range of rows has been
* added to the Grid body. {@link CellFocusHandler} will fix indices
* accordingly.
*
* @param added
* a range of added rows
*/
public void rowsAddedToBody(Range added) {
boolean bodyHasFocus = (containerWithFocus == escalator.getBody());
boolean insertionIsAboveFocusedCell = (added.getStart() <= rowWithFocus);
if (bodyHasFocus && insertionIsAboveFocusedCell) {
rowWithFocus += added.length();
rowWithFocus = Math.min(rowWithFocus, escalator.getBody()
.getRowCount() - 1);
refreshRow(rowWithFocus);
}
}
/**
* Informs {@link CellFocusHandler} that certain range of rows has been
* removed from the Grid body. {@link CellFocusHandler} will fix indices
* accordingly.
*
* @param removed
* a range of removed rows
*/
public void rowsRemovedFromBody(Range removed) {
if (containerWithFocus != escalator.getBody()) {
return;
} else if (!removed.contains(rowWithFocus)) {
if (removed.getStart() > rowWithFocus) {
return;
}
rowWithFocus = rowWithFocus - removed.length();
} else {
if (containerWithFocus.getRowCount() > removed.getEnd()) {
rowWithFocus = removed.getStart();
} else if (removed.getStart() > 0) {
rowWithFocus = removed.getStart() - 1;
} else {
if (escalator.getHeader().getRowCount() > 0) {
rowWithFocus = Math.min(lastFocusedHeaderRow, escalator
.getHeader().getRowCount() - 1);
containerWithFocus = escalator.getHeader();
} else if (escalator.getFooter().getRowCount() > 0) {
rowWithFocus = Math.min(lastFocusedFooterRow, escalator
.getFooter().getRowCount() - 1);
containerWithFocus = escalator.getFooter();
}
}
}
refreshRow(rowWithFocus);
}
}
public final class SelectionColumn extends Column {
private boolean initDone = false;
SelectionColumn(final Renderer selectColumnRenderer) {
super(selectColumnRenderer);
}
void initDone() {
if (getSelectionModel() instanceof SelectionModel.Multi
&& header.getDefaultRow() != null) {
/*
* TODO: Currently the select all check box is shown when multi
* selection is in use. This might result in malfunctions if no
* SelectAllHandlers are present.
*
* Later on this could be fixed so that it check such handlers
* exist.
*/
final SelectionModel.Multi model = (Multi) getSelectionModel();
final CheckBox checkBox = new CheckBox();
checkBox.addValueChangeHandler(new ValueChangeHandler() {
@Override
public void onValueChange(ValueChangeEvent event) {
if (event.getValue()) {
fireEvent(new SelectAllEvent(model));
} else {
model.deselectAll();
}
}
});
header.getDefaultRow().getCell(this).setWidget(checkBox);
}
setWidth(-1);
setEditable(false);
initDone = true;
}
@Override
public Column setWidth(double pixels) {
if (pixels != getWidth() && initDone) {
throw new UnsupportedOperationException("The selection "
+ "column cannot be modified after init");
} else {
super.setWidth(pixels);
}
return this;
}
@Override
public Boolean getValue(T row) {
return Boolean.valueOf(isSelected(row));
}
@Override
public Column setExpandRatio(int ratio) {
throw new UnsupportedOperationException(
"can't change the expand ratio of the selection column");
}
@Override
public int getExpandRatio() {
return 0;
}
@Override
public Column setMaximumWidth(double pixels) {
throw new UnsupportedOperationException(
"can't change the maximum width of the selection column");
}
@Override
public double getMaximumWidth() {
return -1;
}
@Override
public Column setMinimumWidth(double pixels) {
throw new UnsupportedOperationException(
"can't change the minimum width of the selection column");
}
@Override
public double getMinimumWidth() {
return -1;
}
@Override
public Column setEditable(boolean editable) {
if (initDone) {
throw new UnsupportedOperationException(
"can't set the selection column editable");
}
super.setEditable(editable);
return this;
}
}
/**
* Helper class for performing sorting through the user interface. Controls
* the sort() method, reporting USER as the event originator. This is a
* completely internal class, and is, as such, safe to re-name should a more
* descriptive name come to mind.
*/
private final class UserSorter {
private final Timer timer;
private boolean scheduledMultisort;
private Column column;
private UserSorter() {
timer = new Timer() {
@Override
public void run() {
UserSorter.this.sort(column, scheduledMultisort);
}
};
}
/**
* Toggle sorting for a cell. If the multisort parameter is set to true,
* the cell's sort order is modified as a natural part of a multi-sort
* chain. If false, the sorting order is set to ASCENDING for that
* cell's column. If that column was already the only sorted column in
* the Grid, the sort direction is flipped.
*
* @param cell
* a valid cell reference
* @param multisort
* whether the sort command should act as a multi-sort stack
* or not
*/
public void sort(Column column, boolean multisort) {
if (!columns.contains(column)) {
throw new IllegalArgumentException(
"Given column is not a column in this grid. "
+ column.toString());
}
if (!column.isSortable()) {
return;
}
final SortOrder so = getSortOrder(column);
if (multisort) {
// If the sort order exists, replace existing value with its
// opposite
if (so != null) {
final int idx = sortOrder.indexOf(so);
sortOrder.set(idx, so.getOpposite());
} else {
// If it doesn't, just add a new sort order to the end of
// the list
sortOrder.add(new SortOrder(column));
}
} else {
// Since we're doing single column sorting, first clear the
// list. Then, if the sort order existed, add its opposite,
// otherwise just add a new sort value
int items = sortOrder.size();
sortOrder.clear();
if (so != null && items == 1) {
sortOrder.add(so.getOpposite());
} else {
sortOrder.add(new SortOrder(column));
}
}
// sortOrder has been changed; tell the Grid to re-sort itself by
// user request.
Grid.this.sort(true);
}
/**
* Perform a sort after a delay.
*
* @param delay
* delay, in milliseconds
*/
public void sortAfterDelay(int delay, boolean multisort) {
column = eventCell.getColumn();
scheduledMultisort = multisort;
timer.schedule(delay);
}
/**
* Check if a delayed sort command has been issued but not yet carried
* out.
*
* @return a boolean value
*/
public boolean isDelayedSortScheduled() {
return timer.isRunning();
}
/**
* Cancel a scheduled sort.
*/
public void cancelDelayedSort() {
timer.cancel();
}
}
/** @see Grid#autoColumnWidthsRecalculator */
private class AutoColumnWidthsRecalculator {
private final ScheduledCommand calculateCommand = new ScheduledCommand() {
@Override
public void execute() {
if (!isScheduled) {
// something cancelled running this.
return;
}
if (header.markAsDirty || footer.markAsDirty) {
if (rescheduleCount < 10) {
/*
* Headers and footers are rendered as finally, this way
* we re-schedule this loop as finally, at the end of
* the queue, so that the headers have a chance to
* render themselves.
*/
Scheduler.get().scheduleFinally(this);
rescheduleCount++;
} else {
/*
* We've tried too many times reschedule finally. Seems
* like something is being deferred. Let the queue
* execute and retry again.
*/
rescheduleCount = 0;
Scheduler.get().scheduleDeferred(this);
}
} else if (dataIsBeingFetched) {
Scheduler.get().scheduleDeferred(this);
} else {
calculate();
}
}
};
private int rescheduleCount = 0;
private boolean isScheduled;
/**
* Calculates and applies column widths, taking into account fixed
* widths and column expand rules
*
* @param immediately
* true
if the widths should be executed
* immediately (ignoring lazy loading completely), or
* false
if the command should be run after a
* while (duplicate non-immediately invocations are ignored).
* @see Column#setWidth(double)
* @see Column#setExpandRatio(int)
* @see Column#setMinimumWidth(double)
* @see Column#setMaximumWidth(double)
*/
public void schedule() {
if (!isScheduled) {
isScheduled = true;
Scheduler.get().scheduleFinally(calculateCommand);
}
}
private void calculate() {
isScheduled = false;
rescheduleCount = 0;
assert !dataIsBeingFetched : "Trying to calculate column widths even though data is still being fetched.";
if (columnsAreGuaranteedToBeWiderThanGrid()) {
applyColumnWidths();
} else {
applyColumnWidthsWithExpansion();
}
}
private boolean columnsAreGuaranteedToBeWiderThanGrid() {
double freeSpace = escalator.getInnerWidth();
for (Column column : getColumns()) {
if (column.getWidth() >= 0) {
freeSpace -= column.getWidth();
} else if (column.getMinimumWidth() >= 0) {
freeSpace -= column.getMinimumWidth();
}
}
return freeSpace < 0;
}
@SuppressWarnings("boxing")
private void applyColumnWidths() {
/* Step 1: Apply all column widths as they are. */
Map selfWidths = new LinkedHashMap();
List> columns = getColumns();
for (int index = 0; index < columns.size(); index++) {
selfWidths.put(index, columns.get(index).getWidth());
}
Grid.this.escalator.getColumnConfiguration().setColumnWidths(
selfWidths);
/*
* Step 2: Make sure that each column ends up obeying their min/max
* width constraints if defined as autowidth. If constraints are
* violated, fix it.
*/
Map constrainedWidths = new LinkedHashMap();
for (int index = 0; index < columns.size(); index++) {
Column column = columns.get(index);
boolean hasAutoWidth = column.getWidth() < 0;
if (!hasAutoWidth) {
continue;
}
// TODO: bug: these don't honor the CSS max/min. :(
double actualWidth = column.getWidthActual();
if (actualWidth < getMinWidth(column)) {
constrainedWidths.put(index, column.getMinimumWidth());
} else if (actualWidth > getMaxWidth(column)) {
constrainedWidths.put(index, column.getMaximumWidth());
}
}
Grid.this.escalator.getColumnConfiguration().setColumnWidths(
constrainedWidths);
}
private void applyColumnWidthsWithExpansion() {
boolean defaultExpandRatios = true;
int totalRatios = 0;
double reservedPixels = 0;
final Set> columnsToExpand = new HashSet>();
List> nonFixedColumns = new ArrayList>();
Map columnSizes = new HashMap();
/*
* Set all fixed widths and also calculate the size-to-fit widths
* for the autocalculated columns.
*
* This way we know with how many pixels we have left to expand the
* rest.
*/
for (Column column : getColumns()) {
final double widthAsIs = column.getWidth();
final boolean isFixedWidth = widthAsIs >= 0;
final double widthFixed = Math.max(widthAsIs,
column.getMinimumWidth());
defaultExpandRatios = defaultExpandRatios
&& (column.getExpandRatio() == -1 || column == selectionColumn);
if (isFixedWidth) {
columnSizes.put(indexOfColumn(column), widthFixed);
reservedPixels += widthFixed;
} else {
nonFixedColumns.add(column);
columnSizes.put(indexOfColumn(column), -1.0d);
}
}
setColumnSizes(columnSizes);
for (Column column : nonFixedColumns) {
final int expandRatio = (defaultExpandRatios ? 1 : column
.getExpandRatio());
final double newWidth = column.getWidthActual();
final double maxWidth = getMaxWidth(column);
boolean shouldExpand = newWidth < maxWidth && expandRatio > 0
&& column != selectionColumn;
if (shouldExpand) {
totalRatios += expandRatio;
columnsToExpand.add(column);
}
reservedPixels += newWidth;
columnSizes.put(indexOfColumn(column), newWidth);
}
/*
* Now that we know how many pixels we need at the very least, we
* can distribute the remaining pixels to all columns according to
* their expand ratios.
*/
double pixelsToDistribute = escalator.getInnerWidth()
- reservedPixels;
if (pixelsToDistribute <= 0 || totalRatios <= 0) {
return;
}
/*
* Check for columns that hit their max width. Adjust
* pixelsToDistribute and totalRatios accordingly. Recheck. Stop
* when no new columns hit their max width
*/
boolean aColumnHasMaxedOut;
do {
aColumnHasMaxedOut = false;
final double widthPerRatio = pixelsToDistribute / totalRatios;
final Iterator> i = columnsToExpand.iterator();
while (i.hasNext()) {
final Column column = i.next();
final int expandRatio = getExpandRatio(column,
defaultExpandRatios);
final double autoWidth = columnSizes
.get(indexOfColumn(column));
final double maxWidth = getMaxWidth(column);
double expandedWidth = autoWidth + widthPerRatio
* expandRatio;
if (maxWidth <= expandedWidth) {
i.remove();
totalRatios -= expandRatio;
aColumnHasMaxedOut = true;
pixelsToDistribute -= maxWidth - autoWidth;
columnSizes.put(indexOfColumn(column), maxWidth);
}
}
} while (aColumnHasMaxedOut);
if (totalRatios <= 0 && columnsToExpand.isEmpty()) {
setColumnSizes(columnSizes);
return;
}
assert pixelsToDistribute > 0 : "We've run out of pixels to distribute ("
+ pixelsToDistribute
+ "px to "
+ totalRatios
+ " ratios between " + columnsToExpand.size() + " columns)";
assert totalRatios > 0 && !columnsToExpand.isEmpty() : "Bookkeeping out of sync. Ratios: "
+ totalRatios + " Columns: " + columnsToExpand.size();
/*
* If we still have anything left, distribute the remaining pixels
* to the remaining columns.
*/
final double widthPerRatio;
int leftOver = 0;
if (BrowserInfo.get().isIE8() || BrowserInfo.get().isIE9()
|| BrowserInfo.getBrowserString().contains("PhantomJS")) {
// These browsers report subpixels as integers. this usually
// results into issues..
widthPerRatio = (int) (pixelsToDistribute / totalRatios);
leftOver = (int) (pixelsToDistribute - widthPerRatio
* totalRatios);
} else {
widthPerRatio = pixelsToDistribute / totalRatios;
}
for (Column column : columnsToExpand) {
final int expandRatio = getExpandRatio(column,
defaultExpandRatios);
final double autoWidth = columnSizes.get(indexOfColumn(column));
double totalWidth = autoWidth + widthPerRatio * expandRatio;
if (leftOver > 0) {
totalWidth += 1;
leftOver--;
}
columnSizes.put(indexOfColumn(column), totalWidth);
totalRatios -= expandRatio;
}
assert totalRatios == 0 : "Bookkeeping error: there were still some ratios left undistributed: "
+ totalRatios;
/*
* Check the guarantees for minimum width and scoot back the columns
* that don't care.
*/
boolean minWidthsCausedReflows;
do {
minWidthsCausedReflows = false;
/*
* First, let's check which columns were too cramped, and expand
* them. Also keep track on how many pixels we grew - we need to
* remove those pixels from other columns
*/
double pixelsToRemoveFromOtherColumns = 0;
for (Column column : getColumns()) {
/*
* We can't iterate over columnsToExpand, even though that
* would be convenient. This is because some column without
* an expand ratio might still have a min width - those
* wouldn't show up in that set.
*/
double minWidth = getMinWidth(column);
double currentWidth = columnSizes
.get(indexOfColumn(column));
boolean hasAutoWidth = column.getWidth() < 0;
if (hasAutoWidth && currentWidth < minWidth) {
columnSizes.put(indexOfColumn(column), minWidth);
pixelsToRemoveFromOtherColumns += (minWidth - currentWidth);
minWidthsCausedReflows = true;
/*
* Remove this column form the set if it exists. This
* way we make sure that it doesn't get shrunk in the
* next step.
*/
columnsToExpand.remove(column);
}
}
/*
* Now we need to shrink the remaining columns according to
* their ratios. Recalculate the sum of remaining ratios.
*/
totalRatios = 0;
for (Column column : columnsToExpand) {
totalRatios += getExpandRatio(column, defaultExpandRatios);
}
final double pixelsToRemovePerRatio = pixelsToRemoveFromOtherColumns
/ totalRatios;
for (Column column : columnsToExpand) {
final double pixelsToRemove = pixelsToRemovePerRatio
* getExpandRatio(column, defaultExpandRatios);
int colIndex = indexOfColumn(column);
columnSizes.put(colIndex, columnSizes.get(colIndex)
- pixelsToRemove);
}
} while (minWidthsCausedReflows);
// Finally set all the column sizes.
setColumnSizes(columnSizes);
}
private void setColumnSizes(Map columnSizes) {
// Set all widths at once
escalator.getColumnConfiguration().setColumnWidths(columnSizes);
}
private int getExpandRatio(Column column,
boolean defaultExpandRatios) {
int expandRatio = column.getExpandRatio();
if (expandRatio > 0) {
return expandRatio;
} else if (expandRatio < 0) {
assert defaultExpandRatios : "No columns should've expanded";
return 1;
} else {
assert false : "this method should've not been called at all if expandRatio is 0";
return 0;
}
}
/**
* Returns the maximum width of the column, or {@link Double#MAX_VALUE}
* if defined as negative.
*/
private double getMaxWidth(Column column) {
double maxWidth = column.getMaximumWidth();
if (maxWidth >= 0) {
return maxWidth;
} else {
return Double.MAX_VALUE;
}
}
/**
* Returns the minimum width of the column, or {@link Double#MIN_VALUE}
* if defined as negative.
*/
private double getMinWidth(Column column) {
double minWidth = column.getMinimumWidth();
if (minWidth >= 0) {
return minWidth;
} else {
return Double.MIN_VALUE;
}
}
/**
* Check whether the auto width calculation is currently scheduled.
*
* @return true
if auto width calculation is currently
* scheduled
*/
public boolean isScheduled() {
return isScheduled;
}
}
/**
* Escalator used internally by grid to render the rows
*/
private Escalator escalator = GWT.create(Escalator.class);
private final Header header = GWT.create(Header.class);
private final Footer footer = GWT.create(Footer.class);
/**
* List of columns in the grid. Order defines the visible order.
*/
private List> columns = new ArrayList>();
/**
* The datasource currently in use. Note: it is null
* on initialization, but not after that.
*/
private DataSource dataSource;
/**
* Currently available row range in DataSource.
*/
private Range currentDataAvailable = Range.withLength(0, 0);
/**
* The number of frozen columns, 0 freezes the selection column if
* displayed, -1 also prevents selection col from freezing.
*/
private int frozenColumnCount = 0;
/**
* Current sort order. The (private) sort() method reads this list to
* determine the order in which to present rows.
*/
private List sortOrder = new ArrayList();
private Renderer selectColumnRenderer = null;
private SelectionColumn selectionColumn;
private String rowStripeStyleName;
private String rowHasDataStyleName;
private String rowSelectedStyleName;
private String cellFocusStyleName;
private String rowFocusStyleName;
/**
* Current selection model.
*/
private SelectionModel selectionModel;
protected final CellFocusHandler cellFocusHandler;
private final UserSorter sorter = new UserSorter();
private final Editor editor = GWT.create(Editor.class);
private boolean dataIsBeingFetched = false;
/**
* The cell a click event originated from
*
* This is a workaround to make Chrome work like Firefox. In Chrome,
* normally if you start a drag on one cell and release on:
*
* - that same cell, the click event is that {@code
}.
* - a cell on that same row, the click event is the parent {@code
}.
* - a cell on another row, the click event is the table section ancestor
* ({@code }, {@code } or {@code }).
*
*
* @see #onBrowserEvent(Event)
*/
private Cell cellOnPrevMouseDown;
/**
* A scheduled command to re-evaluate the widths of all columns
* that have calculated widths. Most probably called because
* minwidth/maxwidth/expandratio has changed.
*/
private final AutoColumnWidthsRecalculator autoColumnWidthsRecalculator = new AutoColumnWidthsRecalculator();
private boolean enabled = true;
/**
* Enumeration for easy setting of selection mode.
*/
public enum SelectionMode {
/**
* Shortcut for {@link SelectionModelSingle}.
*/
SINGLE {
@Override
protected
SelectionModel createModel() {
return new SelectionModelSingle();
}
},
/**
* Shortcut for {@link SelectionModelMulti}.
*/
MULTI {
@Override
protected SelectionModel createModel() {
return new SelectionModelMulti();
}
},
/**
* Shortcut for {@link SelectionModelNone}.
*/
NONE {
@Override
protected SelectionModel createModel() {
return new SelectionModelNone();
}
};
protected abstract SelectionModel createModel();
}
/**
* Base class for grid columns internally used by the Grid. The user should
* use {@link Column} when creating new columns.
*
* @param
* the column type
*
* @param
* the row type
*/
public static abstract class Column {
/**
* Default renderer for GridColumns. Renders everything into text
* through {@link Object#toString()}.
*/
private final class DefaultTextRenderer implements Renderer