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 }