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 }