/*
* Copyright (c) 2011, 2016, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package javafx.scene.control.skin;
import java.util.*;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.ListChangeListener;
import javafx.collections.WeakListChangeListener;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Side;
import javafx.geometry.VPos;
import javafx.scene.control.CheckMenuItem;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableColumnBase;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.Rectangle;
import com.sun.javafx.scene.control.skin.resources.ControlResources;
Region responsible for painting the entire row of column headers.
See Also: Since: 9
/**
* Region responsible for painting the entire row of column headers.
*
* @since 9
* @see javafx.scene.control.TableView
* @see TableViewSkin
* @see javafx.scene.control.TreeTableView
* @see TreeTableViewSkin
*/
public class TableHeaderRow extends StackPane {
/***************************************************************************
* *
* Static Fields *
* *
**************************************************************************/
*
Private Fields *
*
/***************************************************************************
* *
* Private Fields *
* *
**************************************************************************/
// JDK-8090129: This constant should not be static, because the
// Locale may change between instances.
private final String MENU_SEPARATOR =
ControlResources.getString("TableView.nestedColumnControlMenuSeparator");
private final VirtualFlow flow;
final TableViewSkinBase<?,?,?,?,?> tableSkin;
private Map<TableColumnBase, CheckMenuItem> columnMenuItems = new HashMap<TableColumnBase, CheckMenuItem>();
private double scrollX;
private double tableWidth;
private Rectangle clip;
private TableColumnHeader reorderingRegion;
This is the ghosted region representing the table column that is being
dragged. It moves along the x-axis but is fixed in the y-axis.
/**
* This is the ghosted region representing the table column that is being
* dragged. It moves along the x-axis but is fixed in the y-axis.
*/
private StackPane dragHeader;
private final Label dragHeaderLabel = new Label();
private Region filler;
This is the region where the user can interact with to show/hide columns.
It is positioned in the top-right hand corner of the TableHeaderRow, and
when clicked shows a PopupMenu consisting of all leaf columns.
/**
* This is the region where the user can interact with to show/hide columns.
* It is positioned in the top-right hand corner of the TableHeaderRow, and
* when clicked shows a PopupMenu consisting of all leaf columns.
*/
private Pane cornerRegion;
PopupMenu shown to users to allow for them to hide/show columns in the
table.
/**
* PopupMenu shown to users to allow for them to hide/show columns in the
* table.
*/
private ContextMenu columnPopupMenu;
There are two different mouse dragged event handlers in the header code.
Firstly, the column reordering functionality, and secondly, the column
resizing functionality. Because these are handled in separate classes and
with separate event handlers, we occasionally run into the issue where
both event handlers were being called, resulting in bad UX. To remove this
issue, we lock when the column dragging happens, and prevent resize operations
from taking place.
/**
* There are two different mouse dragged event handlers in the header code.
* Firstly, the column reordering functionality, and secondly, the column
* resizing functionality. Because these are handled in separate classes and
* with separate event handlers, we occasionally run into the issue where
* both event handlers were being called, resulting in bad UX. To remove this
* issue, we lock when the column dragging happens, and prevent resize operations
* from taking place.
*/
boolean columnDragLock = false;
*
Listeners *
*
/***************************************************************************
* *
* Listeners *
* *
**************************************************************************/
private InvalidationListener tableWidthListener = o -> updateTableWidth();
private InvalidationListener tablePaddingListener = o -> updateTableWidth();
// This is necessary for RT-20300 (but was updated for RT-20840)
private ListChangeListener visibleLeafColumnsListener = c -> getRootHeader().setHeadersNeedUpdate();
private final ListChangeListener tableColumnsListener = c -> {
while (c.next()) {
updateTableColumnListeners(c.getAddedSubList(), c.getRemoved());
}
};
private final InvalidationListener columnTextListener = observable -> {
TableColumnBase<?,?> column = (TableColumnBase<?,?>) ((StringProperty)observable).getBean();
CheckMenuItem menuItem = columnMenuItems.get(column);
if (menuItem != null) {
menuItem.setText(getText(column.getText(), column));
}
};
private final WeakInvalidationListener weakTableWidthListener =
new WeakInvalidationListener(tableWidthListener);
private final WeakInvalidationListener weakTablePaddingListener =
new WeakInvalidationListener(tablePaddingListener);
private final WeakListChangeListener weakVisibleLeafColumnsListener =
new WeakListChangeListener(visibleLeafColumnsListener);
private final WeakListChangeListener weakTableColumnsListener =
new WeakListChangeListener(tableColumnsListener);
private final WeakInvalidationListener weakColumnTextListener =
new WeakInvalidationListener(columnTextListener);
/***************************************************************************
* *
* Constructor *
* *
**************************************************************************/
Creates a new TableHeaderRow instance to visually represent the column header area of controls such as TableView
and TreeTableView
. Params: - skin – The skin used by the UI control.
/**
* Creates a new TableHeaderRow instance to visually represent the column
* header area of controls such as {@link javafx.scene.control.TableView} and
* {@link javafx.scene.control.TreeTableView}.
*
* @param skin The skin used by the UI control.
*/
public TableHeaderRow(final TableViewSkinBase skin) {
this.tableSkin = skin;
this.flow = skin.flow;
getStyleClass().setAll("column-header-background");
// clip the header so it doesn't show outside of the table bounds
clip = new Rectangle();
clip.setSmooth(false);
clip.heightProperty().bind(heightProperty());
setClip(clip);
// listen to table width to keep header in sync
updateTableWidth();
tableSkin.getSkinnable().widthProperty().addListener(weakTableWidthListener);
tableSkin.getSkinnable().paddingProperty().addListener(weakTablePaddingListener);
TableSkinUtils.getVisibleLeafColumns(skin).addListener(weakVisibleLeafColumnsListener);
// popup menu for hiding/showing columns
columnPopupMenu = new ContextMenu();
updateTableColumnListeners(TableSkinUtils.getColumns(tableSkin), Collections.<TableColumnBase<?,?>>emptyList());
TableSkinUtils.getVisibleLeafColumns(skin).addListener(weakTableColumnsListener);
TableSkinUtils.getColumns(tableSkin).addListener(weakTableColumnsListener);
// drag header region. Used to indicate the current column being reordered
dragHeader = new StackPane();
dragHeader.setVisible(false);
dragHeader.getStyleClass().setAll("column-drag-header");
dragHeader.setManaged(false);
dragHeader.setMouseTransparent(true);
dragHeader.getChildren().add(dragHeaderLabel);
// the header lives inside a NestedTableColumnHeader
NestedTableColumnHeader rootHeader = createRootHeader();
setRootHeader(rootHeader);
rootHeader.setFocusTraversable(false);
rootHeader.setTableHeaderRow(this);
// The 'filler' area that extends from the right-most column to the edge
// of the tableview, or up to the 'column control' button
filler = new Region();
filler.getStyleClass().setAll("filler");
// Give focus to the table when an empty area of the header row is clicked.
// This ensures the user knows that the table has focus.
setOnMousePressed(e -> {
skin.getSkinnable().requestFocus();
});
// build the corner region button for showing the popup menu
final StackPane image = new StackPane();
image.setSnapToPixel(false);
image.getStyleClass().setAll("show-hide-column-image");
cornerRegion = new StackPane() {
@Override protected void layoutChildren() {
double imageWidth = image.snappedLeftInset() + image.snappedRightInset();
double imageHeight = image.snappedTopInset() + image.snappedBottomInset();
image.resize(imageWidth, imageHeight);
positionInArea(image, 0, 0, getWidth(), getHeight() - 3,
0, HPos.CENTER, VPos.CENTER);
}
};
cornerRegion.getStyleClass().setAll("show-hide-columns-button");
cornerRegion.getChildren().addAll(image);
BooleanProperty tableMenuButtonVisibleProperty = TableSkinUtils.tableMenuButtonVisibleProperty(skin);
if (tableMenuButtonVisibleProperty != null) {
cornerRegion.visibleProperty().bind(tableMenuButtonVisibleProperty);
};
cornerRegion.setOnMousePressed(me -> {
// show a popupMenu which lists all columns
columnPopupMenu.show(cornerRegion, Side.BOTTOM, 0, 0);
me.consume();
});
// the actual header
// the region that is anchored above the vertical scrollbar
// a 'ghost' of the header being dragged by the user to force column
// reordering
getChildren().addAll(filler, rootHeader, cornerRegion, dragHeader);
}
*
Properties *
*
/***************************************************************************
* *
* Properties *
* *
**************************************************************************/
// --- reordering
private BooleanProperty reordering = new SimpleBooleanProperty(this, "reordering", false) {
@Override protected void invalidated() {
TableColumnHeader r = getReorderingRegion();
if (r != null) {
double dragHeaderHeight = r.getNestedColumnHeader() != null ?
r.getNestedColumnHeader().getHeight() :
getReorderingRegion().getHeight();
dragHeader.resize(dragHeader.getWidth(), dragHeaderHeight);
dragHeader.setTranslateY(getHeight() - dragHeaderHeight);
}
dragHeader.setVisible(isReordering());
}
};
final void setReordering(boolean value) {
this.reordering.set(value);
}
final boolean isReordering() {
return reordering.get();
}
final BooleanProperty reorderingProperty() {
return reordering;
}
// --- root header
/*
* The header row is actually just one NestedTableColumnHeader that spans
* the entire width. Nested within this is the TableColumnHeader's and
* NestedTableColumnHeader's, as necessary. This makes it nice and clean
* to handle column reordering - we basically enforce the rule that column
* reordering only occurs within a single NestedTableColumnHeader, and only
* at that level.
*/
private ReadOnlyObjectWrapper<NestedTableColumnHeader> rootHeader = new ReadOnlyObjectWrapper<>(this, "rootHeader");
private final ReadOnlyObjectProperty<NestedTableColumnHeader> rootHeaderProperty() {
return rootHeader.getReadOnlyProperty();
}
final NestedTableColumnHeader getRootHeader() {
return rootHeader.get();
}
private final void setRootHeader(NestedTableColumnHeader value) {
rootHeader.set(value);
}
/***************************************************************************
* *
* Public API *
* *
**************************************************************************/
{@inheritDoc} /** {@inheritDoc} */
@Override protected void layoutChildren() {
double x = scrollX;
double headerWidth = snapSizeX(getRootHeader().prefWidth(-1));
double prefHeight = getHeight() - snappedTopInset() - snappedBottomInset();
double cornerWidth = snapSizeX(flow.getVbar().prefWidth(-1));
// position the main nested header
getRootHeader().resizeRelocate(x, snappedTopInset(), headerWidth, prefHeight);
// position the filler region
final Control control = tableSkin.getSkinnable();
if (control == null) {
return;
}
final BooleanProperty tableMenuButtonVisibleProperty = TableSkinUtils.tableMenuButtonVisibleProperty(tableSkin);
final double controlInsets = control.snappedLeftInset() + control.snappedRightInset();
double fillerWidth = tableWidth - headerWidth + filler.getInsets().getLeft() - controlInsets;
fillerWidth -= tableMenuButtonVisibleProperty != null && tableMenuButtonVisibleProperty.get() ? cornerWidth : 0;
filler.setVisible(fillerWidth > 0);
if (fillerWidth > 0) {
filler.resizeRelocate(x + headerWidth, snappedTopInset(), fillerWidth, prefHeight);
}
// position the top-right rectangle (which sits above the scrollbar)
cornerRegion.resizeRelocate(tableWidth - cornerWidth, snappedTopInset(), cornerWidth, prefHeight);
}
{@inheritDoc} /** {@inheritDoc} */
@Override protected double computePrefWidth(double height) {
return getRootHeader().prefWidth(height);
}
{@inheritDoc} /** {@inheritDoc} */
@Override protected double computeMinHeight(double width) {
return computePrefHeight(width);
}
{@inheritDoc} /** {@inheritDoc} */
@Override protected double computePrefHeight(double width) {
// we hardcode 24.0 here to avoid RT-37616, where the
// entire header row would disappear when all columns were hidden.
double headerPrefHeight = getRootHeader().prefHeight(width);
headerPrefHeight = headerPrefHeight == 0.0 ? 24.0 : headerPrefHeight;
return snappedTopInset() + headerPrefHeight + snappedBottomInset();
}
// used to be protected to allow subclasses to modify the horizontal scrolling,
// but made private again for JDK 9
void updateScrollX() {
scrollX = flow.getHbar().isVisible() ? -flow.getHbar().getValue() : 0.0F;
requestLayout();
// Fix for RT-36392: without this call even though we call requestLayout()
// we don't seem to ever see the layoutChildren() method above called,
// which means the layout is not always updated to use the latest scrollX.
layout();
}
// used to be protected to allow subclass to customise the width, to allow for features
// such as row headers, but made private again for JDK 9
private void updateTableWidth() {
// snapping added for RT-19428
final Control c = tableSkin.getSkinnable();
if (c == null) {
this.tableWidth = 0;
} else {
Insets insets = c.getInsets() == null ? Insets.EMPTY : c.getInsets();
double padding = snapSizeX(insets.getLeft()) + snapSizeX(insets.getRight());
this.tableWidth = snapSizeX(c.getWidth()) - padding;
}
clip.setWidth(tableWidth);
}
Creates a new NestedTableColumnHeader instance. By default this method should not be overridden, but in some
circumstances it makes sense (e.g. testing, or when extreme customization is desired).
Returns: A new NestedTableColumnHeader instance.
/**
* Creates a new NestedTableColumnHeader instance. By default this method should not be overridden, but in some
* circumstances it makes sense (e.g. testing, or when extreme customization is desired).
*
* @return A new NestedTableColumnHeader instance.
*/
protected NestedTableColumnHeader createRootHeader() {
return new NestedTableColumnHeader(null);
}
*
Private Implementation *
*
/***************************************************************************
* *
* Private Implementation *
* *
**************************************************************************/
TableColumnHeader getReorderingRegion() {
return reorderingRegion;
}
void setReorderingColumn(TableColumnBase rc) {
dragHeaderLabel.setText(rc == null ? "" : rc.getText());
}
void setReorderingRegion(TableColumnHeader reorderingRegion) {
this.reorderingRegion = reorderingRegion;
if (reorderingRegion != null) {
dragHeader.resize(reorderingRegion.getWidth(), dragHeader.getHeight());
}
}
void setDragHeaderX(double dragHeaderX) {
dragHeader.setTranslateX(dragHeaderX);
}
TableColumnHeader getColumnHeaderFor(final TableColumnBase<?,?> col) {
if (col == null) return null;
List<TableColumnBase<?,?>> columnChain = new ArrayList<>();
columnChain.add(col);
TableColumnBase<?,?> parent = col.getParentColumn();
while (parent != null) {
columnChain.add(0, parent);
parent = parent.getParentColumn();
}
// we now have a list from top to bottom of a nested column hierarchy,
// and we can now navigate down to retrieve the header with ease
TableColumnHeader currentHeader = getRootHeader();
for (int depth = 0; depth < columnChain.size(); depth++) {
// this is the column we are looking for at this depth
TableColumnBase<?,?> column = columnChain.get(depth);
// and now we iterate through the nested table column header at this
// level to get the header
currentHeader = getColumnHeaderFor(column, currentHeader);
}
return currentHeader;
}
private TableColumnHeader getColumnHeaderFor(final TableColumnBase<?,?> col, TableColumnHeader currentHeader) {
if (currentHeader instanceof NestedTableColumnHeader) {
List<TableColumnHeader> headers = ((NestedTableColumnHeader)currentHeader).getColumnHeaders();
for (int i = 0; i < headers.size(); i++) {
TableColumnHeader header = headers.get(i);
if (header.getTableColumn() == col) {
return header;
}
}
}
return null;
}
private void updateTableColumnListeners(List<? extends TableColumnBase<?,?>> added, List<? extends TableColumnBase<?,?>> removed) {
// remove binding from all removed items
for (TableColumnBase tc : removed) {
remove(tc);
}
rebuildColumnMenu();
}
private void remove(TableColumnBase<?,?> col) {
if (col == null) return;
CheckMenuItem item = columnMenuItems.remove(col);
if (item != null) {
col.textProperty().removeListener(weakColumnTextListener);
item.selectedProperty().unbindBidirectional(col.visibleProperty());
columnPopupMenu.getItems().remove(item);
}
if (! col.getColumns().isEmpty()) {
for (TableColumnBase tc : col.getColumns()) {
remove(tc);
}
}
}
private void rebuildColumnMenu() {
columnPopupMenu.getItems().clear();
for (TableColumnBase<?,?> col : TableSkinUtils.getColumns(tableSkin)) {
// we only create menu items for leaf columns, visible or not
if (col.getColumns().isEmpty()) {
createMenuItem(col);
} else {
List<TableColumnBase<?,?>> leafColumns = getLeafColumns(col);
for (TableColumnBase<?,?> _col : leafColumns) {
createMenuItem(_col);
}
}
}
}
private List<TableColumnBase<?,?>> getLeafColumns(TableColumnBase<?,?> col) {
List<TableColumnBase<?,?>> leafColumns = new ArrayList<>();
for (TableColumnBase<?,?> _col : col.getColumns()) {
if (_col.getColumns().isEmpty()) {
leafColumns.add(_col);
} else {
leafColumns.addAll(getLeafColumns(_col));
}
}
return leafColumns;
}
private void createMenuItem(TableColumnBase<?,?> col) {
CheckMenuItem item = columnMenuItems.get(col);
if (item == null) {
item = new CheckMenuItem();
columnMenuItems.put(col, item);
}
// bind column text and isVisible so that the menu item is always correct
item.setText(getText(col.getText(), col));
col.textProperty().addListener(weakColumnTextListener);
// ideally we would have API to observe the binding status of a property,
// but for now that doesn't exist, so we set this once and then forget
item.setDisable(col.visibleProperty().isBound());
// fake bidrectional binding (a real one was used here but resulted in JBS-8136468)
item.setSelected(col.isVisible());
final CheckMenuItem _item = item;
item.selectedProperty().addListener(o -> {
if (col.visibleProperty().isBound()) return;
col.setVisible(_item.isSelected());
});
col.visibleProperty().addListener(o -> _item.setSelected(col.isVisible()));
columnPopupMenu.getItems().add(item);
}
/*
* Function used for building the strings in the popup menu
*/
private String getText(String text, TableColumnBase col) {
String s = text;
TableColumnBase parentCol = col.getParentColumn();
while (parentCol != null) {
if (isColumnVisibleInHeader(parentCol, TableSkinUtils.getColumns(tableSkin))) {
s = parentCol.getText() + MENU_SEPARATOR + s;
}
parentCol = parentCol.getParentColumn();
}
return s;
}
// We need to show strings properly. If a column has a parent column which is
// not inserted into the TableView columns list, it effectively doesn't have
// a parent column from the users perspective. As such, we shouldn't include
// the parent column text in the menu. Fixes RT-14482.
private boolean isColumnVisibleInHeader(TableColumnBase col, List columns) {
if (col == null) return false;
for (int i = 0; i < columns.size(); i++) {
TableColumnBase column = (TableColumnBase) columns.get(i);
if (col.equals(column)) return true;
if (! column.getColumns().isEmpty()) {
boolean isVisible = isColumnVisibleInHeader(col, column.getColumns());
if (isVisible) return true;
}
}
return false;
}
}