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