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 javafx.scene.control.TableView 63 * @see TableViewSkin 64 * @see javafx.scene.control.TreeTableView 65 * @see TreeTableViewSkin 66 */ 67 public class TableHeaderRow extends StackPane { 68 69 /*************************************************************************** 70 * * 71 * Static Fields * 72 * * 73 **************************************************************************/ 74 75 76 /*************************************************************************** 77 * * 78 * Private Fields * 79 * * 80 **************************************************************************/ 81 82 // JDK-8090129: This constant should not be static, because the 83 // Locale may change between instances. 84 private final String MENU_SEPARATOR = 85 ControlResources.getString("TableView.nestedColumnControlMenuSeparator"); 86 87 private final VirtualFlow flow; 88 private final TableViewSkinBase<?,?,?,?,?> tableSkin; 89 private Map<TableColumnBase, CheckMenuItem> columnMenuItems = new HashMap<TableColumnBase, CheckMenuItem>(); 90 private double scrollX; 91 private double tableWidth; 92 private Rectangle clip; 93 private TableColumnHeader reorderingRegion; 94 95 /** 96 * This is the ghosted region representing the table column that is being 97 * dragged. It moves along the x-axis but is fixed in the y-axis. 98 */ 99 private StackPane dragHeader; 100 private final Label dragHeaderLabel = new Label(); 101 102 private Region filler; 103 104 /** 105 * This is the region where the user can interact with to show/hide columns. 106 * It is positioned in the top-right hand corner of the TableHeaderRow, and 107 * when clicked shows a PopupMenu consisting of all leaf columns. 108 */ 109 private Pane cornerRegion; 110 111 /** 112 * PopupMenu shown to users to allow for them to hide/show columns in the 113 * table. 114 */ 115 private ContextMenu columnPopupMenu; 116 117 /** 118 * There are two different mouse dragged event handlers in the header code. 119 * Firstly, the column reordering functionality, and secondly, the column 120 * resizing functionality. Because these are handled in separate classes and 121 * with separate event handlers, we occasionally run into the issue where 122 * both event handlers were being called, resulting in bad UX. To remove this 123 * issue, we lock when the column dragging happens, and prevent resize operations 124 * from taking place. 125 */ 126 boolean columnDragLock = false; 127 128 129 130 /*************************************************************************** 131 * * 132 * Listeners * 133 * * 134 **************************************************************************/ 135 136 private InvalidationListener tableWidthListener = o -> updateTableWidth(); 137 138 private InvalidationListener tablePaddingListener = o -> updateTableWidth(); 139 140 // This is necessary for RT-20300 (but was updated for RT-20840) 141 private ListChangeListener visibleLeafColumnsListener = c -> getRootHeader().setHeadersNeedUpdate(); 142 143 private final ListChangeListener tableColumnsListener = c -> { 144 while (c.next()) { 145 updateTableColumnListeners(c.getAddedSubList(), c.getRemoved()); 146 } 147 }; 148 149 private final InvalidationListener columnTextListener = observable -> { 150 TableColumnBase<?,?> column = (TableColumnBase<?,?>) ((StringProperty)observable).getBean(); 151 CheckMenuItem menuItem = columnMenuItems.get(column); 152 if (menuItem != null) { 153 menuItem.setText(getText(column.getText(), column)); 154 } 155 }; 156 157 private final WeakInvalidationListener weakTableWidthListener = 158 new WeakInvalidationListener(tableWidthListener); 159 160 private final WeakInvalidationListener weakTablePaddingListener = 161 new WeakInvalidationListener(tablePaddingListener); 162 163 private final WeakListChangeListener weakVisibleLeafColumnsListener = 164 new WeakListChangeListener(visibleLeafColumnsListener); 165 166 private final WeakListChangeListener weakTableColumnsListener = 167 new WeakListChangeListener(tableColumnsListener); 168 169 private final WeakInvalidationListener weakColumnTextListener = 170 new WeakInvalidationListener(columnTextListener); 171 172 173 174 /*************************************************************************** 175 * * 176 * Constructor * 177 * * 178 **************************************************************************/ 179 180 /** 181 * Creates a new TableHeaderRow instance to visually represent the column 182 * header area of controls such as {@link javafx.scene.control.TableView} and 183 * {@link javafx.scene.control.TreeTableView}. 184 * 185 * @param skin The skin used by the UI control. 186 */ 187 public TableHeaderRow(final TableViewSkinBase skin) { 188 this.tableSkin = skin; 189 this.flow = skin.flow; 190 191 getStyleClass().setAll("column-header-background"); 192 193 // clip the header so it doesn't show outside of the table bounds 194 clip = new Rectangle(); 195 clip.setSmooth(false); 196 clip.heightProperty().bind(heightProperty()); 197 setClip(clip); 198 199 // listen to table width to keep header in sync 200 updateTableWidth(); 201 tableSkin.getSkinnable().widthProperty().addListener(weakTableWidthListener); 202 tableSkin.getSkinnable().paddingProperty().addListener(weakTablePaddingListener); 203 TableSkinUtils.getVisibleLeafColumns(skin).addListener(weakVisibleLeafColumnsListener); 204 205 // popup menu for hiding/showing columns 206 columnPopupMenu = new ContextMenu(); 207 updateTableColumnListeners(TableSkinUtils.getColumns(tableSkin), Collections.<TableColumnBase<?,?>>emptyList()); 208 TableSkinUtils.getVisibleLeafColumns(skin).addListener(weakTableColumnsListener); 209 TableSkinUtils.getColumns(tableSkin).addListener(weakTableColumnsListener); 210 211 // drag header region. Used to indicate the current column being reordered 212 dragHeader = new StackPane(); 213 dragHeader.setVisible(false); 214 dragHeader.getStyleClass().setAll("column-drag-header"); 215 dragHeader.setManaged(false); 216 dragHeader.setMouseTransparent(true); 217 dragHeader.getChildren().add(dragHeaderLabel); 218 219 // the header lives inside a NestedTableColumnHeader 220 NestedTableColumnHeader rootHeader = createRootHeader(); 221 setRootHeader(rootHeader); 222 rootHeader.setFocusTraversable(false); 223 rootHeader.setTableHeaderRow(this); 224 225 // The 'filler' area that extends from the right-most column to the edge 226 // of the tableview, or up to the 'column control' button 227 filler = new Region(); 228 filler.getStyleClass().setAll("filler"); 229 230 // Give focus to the table when an empty area of the header row is clicked. 231 // This ensures the user knows that the table has focus. 232 setOnMousePressed(e -> { 233 skin.getSkinnable().requestFocus(); 234 }); 235 236 // build the corner region button for showing the popup menu 237 final StackPane image = new StackPane(); 238 image.setSnapToPixel(false); 239 image.getStyleClass().setAll("show-hide-column-image"); 240 cornerRegion = new StackPane() { 241 @Override protected void layoutChildren() { 242 double imageWidth = image.snappedLeftInset() + image.snappedRightInset(); 243 double imageHeight = image.snappedTopInset() + image.snappedBottomInset(); 244 245 image.resize(imageWidth, imageHeight); 246 positionInArea(image, 0, 0, getWidth(), getHeight() - 3, 247 0, HPos.CENTER, VPos.CENTER); 248 } 249 }; 250 cornerRegion.getStyleClass().setAll("show-hide-columns-button"); 251 cornerRegion.getChildren().addAll(image); 252 253 BooleanProperty tableMenuButtonVisibleProperty = TableSkinUtils.tableMenuButtonVisibleProperty(skin); 254 if (tableMenuButtonVisibleProperty != null) { 255 cornerRegion.visibleProperty().bind(tableMenuButtonVisibleProperty); 256 }; 257 258 cornerRegion.setOnMousePressed(me -> { 259 // show a popupMenu which lists all columns 260 columnPopupMenu.show(cornerRegion, Side.BOTTOM, 0, 0); 261 me.consume(); 262 }); 263 264 // the actual header 265 // the region that is anchored above the vertical scrollbar 266 // a 'ghost' of the header being dragged by the user to force column 267 // reordering 268 getChildren().addAll(filler, rootHeader, cornerRegion, dragHeader); 269 } 270 271 272 273 /*************************************************************************** 274 * * 275 * Properties * 276 * * 277 **************************************************************************/ 278 279 // --- reordering 280 private BooleanProperty reordering = new SimpleBooleanProperty(this, "reordering", false) { 281 @Override protected void invalidated() { 282 TableColumnHeader r = getReorderingRegion(); 283 if (r != null) { 284 double dragHeaderHeight = r.getNestedColumnHeader() != null ? 285 r.getNestedColumnHeader().getHeight() : 286 getReorderingRegion().getHeight(); 287 288 dragHeader.resize(dragHeader.getWidth(), dragHeaderHeight); 289 dragHeader.setTranslateY(getHeight() - dragHeaderHeight); 290 } 291 dragHeader.setVisible(isReordering()); 292 } 293 }; 294 final void setReordering(boolean value) { 295 this.reordering.set(value); 296 } 297 final boolean isReordering() { 298 return reordering.get(); 299 } 300 final BooleanProperty reorderingProperty() { 301 return reordering; 302 } 303 304 // --- root header 305 /* 306 * The header row is actually just one NestedTableColumnHeader that spans 307 * the entire width. Nested within this is the TableColumnHeader's and 308 * NestedTableColumnHeader's, as necessary. This makes it nice and clean 309 * to handle column reordering - we basically enforce the rule that column 310 * reordering only occurs within a single NestedTableColumnHeader, and only 311 * at that level. 312 */ 313 private ReadOnlyObjectWrapper<NestedTableColumnHeader> rootHeader = new ReadOnlyObjectWrapper<>(this, "rootHeader"); 314 private final ReadOnlyObjectProperty<NestedTableColumnHeader> rootHeaderProperty() { 315 return rootHeader.getReadOnlyProperty(); 316 } 317 final NestedTableColumnHeader getRootHeader() { 318 return rootHeader.get(); 319 } 320 private final void setRootHeader(NestedTableColumnHeader value) { 321 rootHeader.set(value); 322 } 323 324 325 326 /*************************************************************************** 327 * * 328 * Public API * 329 * * 330 **************************************************************************/ 331 332 /** {@inheritDoc} */ 333 @Override protected void layoutChildren() { 334 double x = scrollX; 335 double headerWidth = snapSizeX(getRootHeader().prefWidth(-1)); 336 double prefHeight = getHeight() - snappedTopInset() - snappedBottomInset(); 337 double cornerWidth = snapSizeX(flow.getVbar().prefWidth(-1)); 338 339 // position the main nested header 340 getRootHeader().resizeRelocate(x, snappedTopInset(), headerWidth, prefHeight); 341 342 // position the filler region 343 final Control control = tableSkin.getSkinnable(); 344 if (control == null) { 345 return; 346 } 347 348 final BooleanProperty tableMenuButtonVisibleProperty = TableSkinUtils.tableMenuButtonVisibleProperty(tableSkin); 349 350 final double controlInsets = control.snappedLeftInset() + control.snappedRightInset(); 351 double fillerWidth = tableWidth - headerWidth + filler.getInsets().getLeft() - controlInsets; 352 fillerWidth -= tableMenuButtonVisibleProperty != null && tableMenuButtonVisibleProperty.get() ? cornerWidth : 0; 353 filler.setVisible(fillerWidth > 0); 354 if (fillerWidth > 0) { 355 filler.resizeRelocate(x + headerWidth, snappedTopInset(), fillerWidth, prefHeight); 356 } 357 358 // position the top-right rectangle (which sits above the scrollbar) 359 cornerRegion.resizeRelocate(tableWidth - cornerWidth, snappedTopInset(), cornerWidth, prefHeight); 360 } 361 362 /** {@inheritDoc} */ 363 @Override protected double computePrefWidth(double height) { 364 return getRootHeader().prefWidth(height); 365 } 366 367 /** {@inheritDoc} */ 368 @Override protected double computeMinHeight(double width) { 369 return computePrefHeight(width); 370 } 371 372 /** {@inheritDoc} */ 373 @Override protected double computePrefHeight(double width) { 374 // we hardcode 24.0 here to avoid RT-37616, where the 375 // entire header row would disappear when all columns were hidden. 376 double headerPrefHeight = getRootHeader().prefHeight(width); 377 headerPrefHeight = headerPrefHeight == 0.0 ? 24.0 : headerPrefHeight; 378 return snappedTopInset() + headerPrefHeight + snappedBottomInset(); 379 } 380 381 // used to be protected to allow subclasses to modify the horizontal scrolling, 382 // but made private again for JDK 9 383 void updateScrollX() { 384 scrollX = flow.getHbar().isVisible() ? -flow.getHbar().getValue() : 0.0F; 385 requestLayout(); 386 387 // Fix for RT-36392: without this call even though we call requestLayout() 388 // we don't seem to ever see the layoutChildren() method above called, 389 // which means the layout is not always updated to use the latest scrollX. 390 layout(); 391 } 392 393 // used to be protected to allow subclass to customise the width, to allow for features 394 // such as row headers, but made private again for JDK 9 395 private void updateTableWidth() { 396 // snapping added for RT-19428 397 final Control c = tableSkin.getSkinnable(); 398 if (c == null) { 399 this.tableWidth = 0; 400 } else { 401 Insets insets = c.getInsets() == null ? Insets.EMPTY : c.getInsets(); 402 double padding = snapSizeX(insets.getLeft()) + snapSizeX(insets.getRight()); 403 this.tableWidth = snapSizeX(c.getWidth()) - padding; 404 } 405 406 clip.setWidth(tableWidth); 407 } 408 409 /** 410 * Creates a new NestedTableColumnHeader instance. By default this method should not be overridden, but in some 411 * circumstances it makes sense (e.g. testing, or when extreme customization is desired). 412 * 413 * @return A new NestedTableColumnHeader instance. 414 */ 415 protected NestedTableColumnHeader createRootHeader() { 416 return new NestedTableColumnHeader(tableSkin, null); 417 } 418 419 420 421 /*************************************************************************** 422 * * 423 * Private Implementation * 424 * * 425 **************************************************************************/ 426 427 TableColumnHeader getReorderingRegion() { 428 return reorderingRegion; 429 } 430 431 void setReorderingColumn(TableColumnBase rc) { 432 dragHeaderLabel.setText(rc == null ? "" : rc.getText()); 433 } 434 435 void setReorderingRegion(TableColumnHeader reorderingRegion) { 436 this.reorderingRegion = reorderingRegion; 437 438 if (reorderingRegion != null) { 439 dragHeader.resize(reorderingRegion.getWidth(), dragHeader.getHeight()); 440 } 441 } 442 443 void setDragHeaderX(double dragHeaderX) { 444 dragHeader.setTranslateX(dragHeaderX); 445 } 446 447 TableColumnHeader getColumnHeaderFor(final TableColumnBase<?,?> col) { 448 if (col == null) return null; 449 List<TableColumnBase<?,?>> columnChain = new ArrayList<>(); 450 columnChain.add(col); 451 452 TableColumnBase<?,?> parent = col.getParentColumn(); 453 while (parent != null) { 454 columnChain.add(0, parent); 455 parent = parent.getParentColumn(); 456 } 457 458 // we now have a list from top to bottom of a nested column hierarchy, 459 // and we can now navigate down to retrieve the header with ease 460 TableColumnHeader currentHeader = getRootHeader(); 461 for (int depth = 0; depth < columnChain.size(); depth++) { 462 // this is the column we are looking for at this depth 463 TableColumnBase<?,?> column = columnChain.get(depth); 464 465 // and now we iterate through the nested table column header at this 466 // level to get the header 467 currentHeader = getColumnHeaderFor(column, currentHeader); 468 } 469 return currentHeader; 470 } 471 472 private TableColumnHeader getColumnHeaderFor(final TableColumnBase<?,?> col, TableColumnHeader currentHeader) { 473 if (currentHeader instanceof NestedTableColumnHeader) { 474 List<TableColumnHeader> headers = ((NestedTableColumnHeader)currentHeader).getColumnHeaders(); 475 476 for (int i = 0; i < headers.size(); i++) { 477 TableColumnHeader header = headers.get(i); 478 if (header.getTableColumn() == col) { 479 return header; 480 } 481 } 482 } 483 484 return null; 485 } 486 487 private void updateTableColumnListeners(List<? extends TableColumnBase<?,?>> added, List<? extends TableColumnBase<?,?>> removed) { 488 // remove binding from all removed items 489 for (TableColumnBase tc : removed) { 490 remove(tc); 491 } 492 493 rebuildColumnMenu(); 494 } 495 496 private void remove(TableColumnBase<?,?> col) { 497 if (col == null) return; 498 499 CheckMenuItem item = columnMenuItems.remove(col); 500 if (item != null) { 501 col.textProperty().removeListener(weakColumnTextListener); 502 item.selectedProperty().unbindBidirectional(col.visibleProperty()); 503 504 columnPopupMenu.getItems().remove(item); 505 } 506 507 if (! col.getColumns().isEmpty()) { 508 for (TableColumnBase tc : col.getColumns()) { 509 remove(tc); 510 } 511 } 512 } 513 514 private void rebuildColumnMenu() { 515 columnPopupMenu.getItems().clear(); 516 517 for (TableColumnBase<?,?> col : TableSkinUtils.getColumns(tableSkin)) { 518 // we only create menu items for leaf columns, visible or not 519 if (col.getColumns().isEmpty()) { 520 createMenuItem(col); 521 } else { 522 List<TableColumnBase<?,?>> leafColumns = getLeafColumns(col); 523 for (TableColumnBase<?,?> _col : leafColumns) { 524 createMenuItem(_col); 525 } 526 } 527 } 528 } 529 530 private List<TableColumnBase<?,?>> getLeafColumns(TableColumnBase<?,?> col) { 531 List<TableColumnBase<?,?>> leafColumns = new ArrayList<>(); 532 533 for (TableColumnBase<?,?> _col : col.getColumns()) { 534 if (_col.getColumns().isEmpty()) { 535 leafColumns.add(_col); 536 } else { 537 leafColumns.addAll(getLeafColumns(_col)); 538 } 539 } 540 541 return leafColumns; 542 } 543 544 private void createMenuItem(TableColumnBase<?,?> col) { 545 CheckMenuItem item = columnMenuItems.get(col); 546 if (item == null) { 547 item = new CheckMenuItem(); 548 columnMenuItems.put(col, item); 549 } 550 551 // bind column text and isVisible so that the menu item is always correct 552 item.setText(getText(col.getText(), col)); 553 col.textProperty().addListener(weakColumnTextListener); 554 555 // ideally we would have API to observe the binding status of a property, 556 // but for now that doesn't exist, so we set this once and then forget 557 item.setDisable(col.visibleProperty().isBound()); 558 559 // fake bidrectional binding (a real one was used here but resulted in JBS-8136468) 560 item.setSelected(col.isVisible()); 561 final CheckMenuItem _item = item; 562 item.selectedProperty().addListener(o -> { 563 if (col.visibleProperty().isBound()) return; 564 col.setVisible(_item.isSelected()); 565 }); 566 col.visibleProperty().addListener(o -> _item.setSelected(col.isVisible())); 567 568 columnPopupMenu.getItems().add(item); 569 } 570 571 /* 572 * Function used for building the strings in the popup menu 573 */ 574 private String getText(String text, TableColumnBase col) { 575 String s = text; 576 TableColumnBase parentCol = col.getParentColumn(); 577 while (parentCol != null) { 578 if (isColumnVisibleInHeader(parentCol, TableSkinUtils.getColumns(tableSkin))) { 579 s = parentCol.getText() + MENU_SEPARATOR + s; 580 } 581 parentCol = parentCol.getParentColumn(); 582 } 583 return s; 584 } 585 586 // We need to show strings properly. If a column has a parent column which is 587 // not inserted into the TableView columns list, it effectively doesn't have 588 // a parent column from the users perspective. As such, we shouldn't include 589 // the parent column text in the menu. Fixes RT-14482. 590 private boolean isColumnVisibleInHeader(TableColumnBase col, List columns) { 591 if (col == null) return false; 592 593 for (int i = 0; i < columns.size(); i++) { 594 TableColumnBase column = (TableColumnBase) columns.get(i); 595 if (col.equals(column)) return true; 596 597 if (! column.getColumns().isEmpty()) { 598 boolean isVisible = isColumnVisibleInHeader(col, column.getColumns()); 599 if (isVisible) return true; 600 } 601 } 602 603 return false; 604 } 605 }