1 /* 2 * Copyright (c) 2011, 2015, Oracle and/or its affiliates. All rights reserved. 3 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. 4 * 5 * This code is free software; you can redistribute it and/or modify it 6 * under the terms of the GNU General Public License version 2 only, as 7 * published by the Free Software Foundation. Oracle designates this 8 * particular file as subject to the "Classpath" exception as provided 9 * by Oracle in the LICENSE file that accompanied this code. 10 * 11 * This code is distributed in the hope that it will be useful, but WITHOUT 12 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 13 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 14 * version 2 for more details (a copy is included in the LICENSE file that 15 * accompanied this code). 16 * 17 * You should have received a copy of the GNU General Public License version 18 * 2 along with this work; if not, write to the Free Software Foundation, 19 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. 20 * 21 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA 22 * or visit www.oracle.com if you need additional information or have any 23 * questions. 24 */ 25 26 package javafx.scene.control.skin; 27 28 import java.util.*; 29 30 import javafx.beans.InvalidationListener; 31 import javafx.beans.WeakInvalidationListener; 32 import javafx.beans.property.BooleanProperty; 33 import javafx.beans.property.ObjectProperty; 34 import javafx.beans.property.ReadOnlyObjectProperty; 35 import javafx.beans.property.ReadOnlyObjectWrapper; 36 import javafx.beans.property.SimpleBooleanProperty; 37 import javafx.beans.property.SimpleObjectProperty; 38 import javafx.beans.property.StringProperty; 39 import javafx.collections.ListChangeListener; 40 import javafx.collections.WeakListChangeListener; 41 import javafx.geometry.HPos; 42 import javafx.geometry.Insets; 43 import javafx.geometry.Side; 44 import javafx.geometry.VPos; 45 import javafx.scene.control.CheckMenuItem; 46 import javafx.scene.control.ContextMenu; 47 import javafx.scene.control.Control; 48 import javafx.scene.control.Label; 49 import javafx.scene.control.TableColumn; 50 import javafx.scene.control.TableColumnBase; 51 import javafx.scene.layout.Pane; 52 import javafx.scene.layout.Region; 53 import javafx.scene.layout.StackPane; 54 import javafx.scene.shape.Rectangle; 55 56 import com.sun.javafx.scene.control.skin.resources.ControlResources; 57 58 /** 59 * Region responsible for painting the entire row of column headers. 60 * 61 * @since 9 62 * @see TableView 63 * @see TableViewSkin 64 * @see TreeTableView 65 * @see TreeTableViewSkin 66 */ 67 public class TableHeaderRow extends StackPane { 68 69 /*************************************************************************** 70 * * 71 * Static Fields * 72 * * 73 **************************************************************************/ 74 75 private static final String MENU_SEPARATOR = 76 ControlResources.getString("TableView.nestedColumnControlMenuSeparator"); 77 78 79 80 /*************************************************************************** 81 * * 82 * Private Fields * 83 * * 84 **************************************************************************/ 85 86 private final VirtualFlow flow; 87 private final TableViewSkinBase<?,?,?,?,?> tableSkin; 88 private Map<TableColumnBase, CheckMenuItem> columnMenuItems = new HashMap<TableColumnBase, CheckMenuItem>(); 89 private double scrollX; 90 private double tableWidth; 91 private Rectangle clip; 92 private TableColumnHeader reorderingRegion; 93 94 /** 95 * This is the ghosted region representing the table column that is being 96 * dragged. It moves along the x-axis but is fixed in the y-axis. 97 */ 98 private StackPane dragHeader; 99 private final Label dragHeaderLabel = new Label(); 100 101 private Region filler; 102 103 /** 104 * This is the region where the user can interact with to show/hide columns. 105 * It is positioned in the top-right hand corner of the TableHeaderRow, and 106 * when clicked shows a PopupMenu consisting of all leaf columns. 107 */ 108 private Pane cornerRegion; 109 110 /** 111 * PopupMenu shown to users to allow for them to hide/show columns in the 112 * table. 113 */ 114 private ContextMenu columnPopupMenu; 115 116 117 118 /*************************************************************************** 119 * * 120 * Listeners * 121 * * 122 **************************************************************************/ 123 124 private InvalidationListener tableWidthListener = o -> updateTableWidth(); 125 126 private InvalidationListener tablePaddingListener = o -> updateTableWidth(); 127 128 // This is necessary for RT-20300 (but was updated for RT-20840) 129 private ListChangeListener visibleLeafColumnsListener = c -> getRootHeader().setHeadersNeedUpdate(); 130 131 private final ListChangeListener tableColumnsListener = c -> { 132 while (c.next()) { 133 updateTableColumnListeners(c.getAddedSubList(), c.getRemoved()); 134 } 135 }; 136 137 private final InvalidationListener columnTextListener = observable -> { 138 TableColumnBase<?,?> column = (TableColumnBase<?,?>) ((StringProperty)observable).getBean(); 139 CheckMenuItem menuItem = columnMenuItems.get(column); 140 if (menuItem != null) { 141 menuItem.setText(getText(column.getText(), column)); 142 } 143 }; 144 145 private final WeakInvalidationListener weakTableWidthListener = 146 new WeakInvalidationListener(tableWidthListener); 147 148 private final WeakInvalidationListener weakTablePaddingListener = 149 new WeakInvalidationListener(tablePaddingListener); 150 151 private final WeakListChangeListener weakVisibleLeafColumnsListener = 152 new WeakListChangeListener(visibleLeafColumnsListener); 153 154 private final WeakListChangeListener weakTableColumnsListener = 155 new WeakListChangeListener(tableColumnsListener); 156 157 private final WeakInvalidationListener weakColumnTextListener = 158 new WeakInvalidationListener(columnTextListener); 159 160 161 162 /*************************************************************************** 163 * * 164 * Constructor * 165 * * 166 **************************************************************************/ 167 168 /** 169 * Creates a new TableHeaderRow instance to visually represent the column 170 * header area of controls such as {@link javafx.scene.control.TableView} and 171 * {@link javafx.scene.control.TreeTableView}. 172 * 173 * @param skin The skin used by the UI control. 174 */ 175 public TableHeaderRow(final TableViewSkinBase skin) { 176 this.tableSkin = skin; 177 this.flow = skin.flow; 178 179 getStyleClass().setAll("column-header-background"); 180 181 // clip the header so it doesn't show outside of the table bounds 182 clip = new Rectangle(); 183 clip.setSmooth(false); 184 clip.heightProperty().bind(heightProperty()); 185 setClip(clip); 186 187 // listen to table width to keep header in sync 188 updateTableWidth(); 189 tableSkin.getSkinnable().widthProperty().addListener(weakTableWidthListener); 190 tableSkin.getSkinnable().paddingProperty().addListener(weakTablePaddingListener); 191 skin.getVisibleLeafColumns().addListener(weakVisibleLeafColumnsListener); 192 193 // popup menu for hiding/showing columns 194 columnPopupMenu = new ContextMenu(); 195 updateTableColumnListeners(tableSkin.getColumns(), Collections.<TableColumnBase<?,?>>emptyList()); 196 tableSkin.getVisibleLeafColumns().addListener(weakTableColumnsListener); 197 tableSkin.getColumns().addListener(weakTableColumnsListener); 198 199 // drag header region. Used to indicate the current column being reordered 200 dragHeader = new StackPane(); 201 dragHeader.setVisible(false); 202 dragHeader.getStyleClass().setAll("column-drag-header"); 203 dragHeader.setManaged(false); 204 dragHeader.getChildren().add(dragHeaderLabel); 205 206 // the header lives inside a NestedTableColumnHeader 207 NestedTableColumnHeader rootHeader = new NestedTableColumnHeader(tableSkin, null); 208 setRootHeader(rootHeader); 209 rootHeader.setFocusTraversable(false); 210 rootHeader.setTableHeaderRow(this); 211 212 // The 'filler' area that extends from the right-most column to the edge 213 // of the tableview, or up to the 'column control' button 214 filler = new Region(); 215 filler.getStyleClass().setAll("filler"); 216 217 // Give focus to the table when an empty area of the header row is clicked. 218 // This ensures the user knows that the table has focus. 219 setOnMousePressed(e -> { 220 skin.getSkinnable().requestFocus(); 221 }); 222 223 // build the corner region button for showing the popup menu 224 final StackPane image = new StackPane(); 225 image.setSnapToPixel(false); 226 image.getStyleClass().setAll("show-hide-column-image"); 227 cornerRegion = new StackPane() { 228 @Override protected void layoutChildren() { 229 double imageWidth = image.snappedLeftInset() + image.snappedRightInset(); 230 double imageHeight = image.snappedTopInset() + image.snappedBottomInset(); 231 232 image.resize(imageWidth, imageHeight); 233 positionInArea(image, 0, 0, getWidth(), getHeight() - 3, 234 0, HPos.CENTER, VPos.CENTER); 235 } 236 }; 237 cornerRegion.getStyleClass().setAll("show-hide-columns-button"); 238 cornerRegion.getChildren().addAll(image); 239 cornerRegion.setVisible(tableSkin.tableMenuButtonVisibleProperty().get()); 240 tableSkin.tableMenuButtonVisibleProperty().addListener(valueModel -> { 241 cornerRegion.setVisible(tableSkin.tableMenuButtonVisibleProperty().get()); 242 requestLayout(); 243 }); 244 cornerRegion.setOnMousePressed(me -> { 245 // show a popupMenu which lists all columns 246 columnPopupMenu.show(cornerRegion, Side.BOTTOM, 0, 0); 247 me.consume(); 248 }); 249 250 // the actual header 251 // the region that is anchored above the vertical scrollbar 252 // a 'ghost' of the header being dragged by the user to force column 253 // reordering 254 getChildren().addAll(filler, rootHeader, cornerRegion, dragHeader); 255 } 256 257 258 259 /*************************************************************************** 260 * * 261 * Properties * 262 * * 263 **************************************************************************/ 264 265 // --- reordering 266 private BooleanProperty reordering = new SimpleBooleanProperty(this, "reordering", false) { 267 @Override protected void invalidated() { 268 TableColumnHeader r = getReorderingRegion(); 269 if (r != null) { 270 double dragHeaderHeight = r.getNestedColumnHeader() != null ? 271 r.getNestedColumnHeader().getHeight() : 272 getReorderingRegion().getHeight(); 273 274 dragHeader.resize(dragHeader.getWidth(), dragHeaderHeight); 275 dragHeader.setTranslateY(getHeight() - dragHeaderHeight); 276 } 277 dragHeader.setVisible(isReordering()); 278 } 279 }; 280 final void setReordering(boolean value) { 281 this.reordering.set(value); 282 } 283 final boolean isReordering() { 284 return reordering.get(); 285 } 286 final BooleanProperty reorderingProperty() { 287 return reordering; 288 } 289 290 // --- root header 291 /* 292 * The header row is actually just one NestedTableColumnHeader that spans 293 * the entire width. Nested within this is the TableColumnHeader's and 294 * NestedTableColumnHeader's, as necessary. This makes it nice and clean 295 * to handle column reordering - we basically enforce the rule that column 296 * reordering only occurs within a single NestedTableColumnHeader, and only 297 * at that level. 298 */ 299 private ReadOnlyObjectWrapper<NestedTableColumnHeader> rootHeader = new ReadOnlyObjectWrapper<>(this, "rootHeader"); 300 private final ReadOnlyObjectProperty<NestedTableColumnHeader> rootHeaderProperty() { 301 return rootHeader.getReadOnlyProperty(); 302 } 303 final NestedTableColumnHeader getRootHeader() { 304 return rootHeader.get(); 305 } 306 private final void setRootHeader(NestedTableColumnHeader value) { 307 rootHeader.set(value); 308 } 309 310 311 312 /*************************************************************************** 313 * * 314 * Public API * 315 * * 316 **************************************************************************/ 317 318 /** {@inheritDoc} */ 319 @Override protected void layoutChildren() { 320 double x = scrollX; 321 double headerWidth = snapSize(getRootHeader().prefWidth(-1)); 322 double prefHeight = getHeight() - snappedTopInset() - snappedBottomInset(); 323 double cornerWidth = snapSize(flow.getVbar().prefWidth(-1)); 324 325 // position the main nested header 326 getRootHeader().resizeRelocate(x, snappedTopInset(), headerWidth, prefHeight); 327 328 // position the filler region 329 final Control control = tableSkin.getSkinnable(); 330 if (control == null) { 331 return; 332 } 333 334 final double controlInsets = control.snappedLeftInset() + control.snappedRightInset(); 335 double fillerWidth = tableWidth - headerWidth + filler.getInsets().getLeft() - controlInsets; 336 fillerWidth -= tableSkin.tableMenuButtonVisibleProperty().get() ? cornerWidth : 0; 337 filler.setVisible(fillerWidth > 0); 338 if (fillerWidth > 0) { 339 filler.resizeRelocate(x + headerWidth, snappedTopInset(), fillerWidth, prefHeight); 340 } 341 342 // position the top-right rectangle (which sits above the scrollbar) 343 cornerRegion.resizeRelocate(tableWidth - cornerWidth, snappedTopInset(), cornerWidth, prefHeight); 344 } 345 346 /** {@inheritDoc} */ 347 @Override protected double computePrefWidth(double height) { 348 return getRootHeader().prefWidth(height); 349 } 350 351 /** {@inheritDoc} */ 352 @Override protected double computeMinHeight(double width) { 353 return computePrefHeight(width); 354 } 355 356 /** {@inheritDoc} */ 357 @Override protected double computePrefHeight(double width) { 358 // we hardcode 24.0 here to avoid RT-37616, where the 359 // entire header row would disappear when all columns were hidden. 360 double headerPrefHeight = getRootHeader().prefHeight(width); 361 headerPrefHeight = headerPrefHeight == 0.0 ? 24.0 : headerPrefHeight; 362 return snappedTopInset() + headerPrefHeight + snappedBottomInset(); 363 } 364 365 // used to be protected to allow subclasses to modify the horizontal scrolling, 366 // but made private again for JDK 9 367 void updateScrollX() { 368 scrollX = flow.getHbar().isVisible() ? -flow.getHbar().getValue() : 0.0F; 369 requestLayout(); 370 371 // Fix for RT-36392: without this call even though we call requestLayout() 372 // we don't seem to ever see the layoutChildren() method above called, 373 // which means the layout is not always updated to use the latest scrollX. 374 layout(); 375 } 376 377 // used to be protected to allow subclass to customise the width, to allow for features 378 // such as row headers, but made private again for JDK 9 379 private void updateTableWidth() { 380 // snapping added for RT-19428 381 final Control c = tableSkin.getSkinnable(); 382 if (c == null) { 383 this.tableWidth = 0; 384 } else { 385 Insets insets = c.getInsets() == null ? Insets.EMPTY : c.getInsets(); 386 double padding = snapSize(insets.getLeft()) + snapSize(insets.getRight()); 387 this.tableWidth = snapSize(c.getWidth()) - padding; 388 } 389 390 clip.setWidth(tableWidth); 391 } 392 393 394 395 /*************************************************************************** 396 * * 397 * Private Implementation * 398 * * 399 **************************************************************************/ 400 401 TableColumnHeader getReorderingRegion() { 402 return reorderingRegion; 403 } 404 405 void setReorderingColumn(TableColumnBase rc) { 406 dragHeaderLabel.setText(rc == null ? "" : rc.getText()); 407 } 408 409 void setReorderingRegion(TableColumnHeader reorderingRegion) { 410 this.reorderingRegion = reorderingRegion; 411 412 if (reorderingRegion != null) { 413 dragHeader.resize(reorderingRegion.getWidth(), dragHeader.getHeight()); 414 } 415 } 416 417 void setDragHeaderX(double dragHeaderX) { 418 dragHeader.setTranslateX(dragHeaderX); 419 } 420 421 TableColumnHeader getColumnHeaderFor(final TableColumnBase<?,?> col) { 422 if (col == null) return null; 423 List<TableColumnBase<?,?>> columnChain = new ArrayList<>(); 424 columnChain.add(col); 425 426 TableColumnBase<?,?> parent = col.getParentColumn(); 427 while (parent != null) { 428 columnChain.add(0, parent); 429 parent = parent.getParentColumn(); 430 } 431 432 // we now have a list from top to bottom of a nested column hierarchy, 433 // and we can now navigate down to retrieve the header with ease 434 TableColumnHeader currentHeader = getRootHeader(); 435 for (int depth = 0; depth < columnChain.size(); depth++) { 436 // this is the column we are looking for at this depth 437 TableColumnBase<?,?> column = columnChain.get(depth); 438 439 // and now we iterate through the nested table column header at this 440 // level to get the header 441 currentHeader = getColumnHeaderFor(column, currentHeader); 442 } 443 return currentHeader; 444 } 445 446 private TableColumnHeader getColumnHeaderFor(final TableColumnBase<?,?> col, TableColumnHeader currentHeader) { 447 if (currentHeader instanceof NestedTableColumnHeader) { 448 List<TableColumnHeader> headers = ((NestedTableColumnHeader)currentHeader).getColumnHeaders(); 449 450 for (int i = 0; i < headers.size(); i++) { 451 TableColumnHeader header = headers.get(i); 452 if (header.getTableColumn() == col) { 453 return header; 454 } 455 } 456 } 457 458 return null; 459 } 460 461 private void updateTableColumnListeners(List<? extends TableColumnBase<?,?>> added, List<? extends TableColumnBase<?,?>> removed) { 462 // remove binding from all removed items 463 for (TableColumnBase tc : removed) { 464 remove(tc); 465 } 466 467 rebuildColumnMenu(); 468 } 469 470 private void remove(TableColumnBase<?,?> col) { 471 if (col == null) return; 472 473 CheckMenuItem item = columnMenuItems.remove(col); 474 if (item != null) { 475 col.textProperty().removeListener(weakColumnTextListener); 476 item.selectedProperty().unbindBidirectional(col.visibleProperty()); 477 478 columnPopupMenu.getItems().remove(item); 479 } 480 481 if (! col.getColumns().isEmpty()) { 482 for (TableColumnBase tc : col.getColumns()) { 483 remove(tc); 484 } 485 } 486 } 487 488 private void rebuildColumnMenu() { 489 columnPopupMenu.getItems().clear(); 490 491 for (TableColumnBase<?,?> col : tableSkin.getColumns()) { 492 // we only create menu items for leaf columns, visible or not 493 if (col.getColumns().isEmpty()) { 494 createMenuItem(col); 495 } else { 496 List<TableColumnBase<?,?>> leafColumns = getLeafColumns(col); 497 for (TableColumnBase<?,?> _col : leafColumns) { 498 createMenuItem(_col); 499 } 500 } 501 } 502 } 503 504 private List<TableColumnBase<?,?>> getLeafColumns(TableColumnBase<?,?> col) { 505 List<TableColumnBase<?,?>> leafColumns = new ArrayList<>(); 506 507 for (TableColumnBase<?,?> _col : col.getColumns()) { 508 if (_col.getColumns().isEmpty()) { 509 leafColumns.add(_col); 510 } else { 511 leafColumns.addAll(getLeafColumns(_col)); 512 } 513 } 514 515 return leafColumns; 516 } 517 518 private void createMenuItem(TableColumnBase<?,?> col) { 519 CheckMenuItem item = columnMenuItems.get(col); 520 if (item == null) { 521 item = new CheckMenuItem(); 522 columnMenuItems.put(col, item); 523 } 524 525 // bind column text and isVisible so that the menu item is always correct 526 item.setText(getText(col.getText(), col)); 527 col.textProperty().addListener(weakColumnTextListener); 528 item.selectedProperty().bindBidirectional(col.visibleProperty()); 529 530 columnPopupMenu.getItems().add(item); 531 } 532 533 /* 534 * Function used for building the strings in the popup menu 535 */ 536 private String getText(String text, TableColumnBase col) { 537 String s = text; 538 TableColumnBase parentCol = col.getParentColumn(); 539 while (parentCol != null) { 540 if (isColumnVisibleInHeader(parentCol, tableSkin.getColumns())) { 541 s = parentCol.getText() + MENU_SEPARATOR + s; 542 } 543 parentCol = parentCol.getParentColumn(); 544 } 545 return s; 546 } 547 548 // We need to show strings properly. If a column has a parent column which is 549 // not inserted into the TableView columns list, it effectively doesn't have 550 // a parent column from the users perspective. As such, we shouldn't include 551 // the parent column text in the menu. Fixes RT-14482. 552 private boolean isColumnVisibleInHeader(TableColumnBase col, List columns) { 553 if (col == null) return false; 554 555 for (int i = 0; i < columns.size(); i++) { 556 TableColumnBase column = (TableColumnBase) columns.get(i); 557 if (col.equals(column)) return true; 558 559 if (! column.getColumns().isEmpty()) { 560 boolean isVisible = isColumnVisibleInHeader(col, column.getColumns()); 561 if (isVisible) return true; 562 } 563 } 564 565 return false; 566 } 567 }