1 /*
   2  * Copyright (c) 2010, 2016, 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 com.sun.javafx.scene.ParentHelper;
  29 import com.sun.javafx.scene.control.FakeFocusTextField;
  30 import com.sun.javafx.scene.control.Properties;
  31 import com.sun.javafx.scene.control.behavior.TextInputControlBehavior;
  32 import com.sun.javafx.scene.input.ExtendedInputMethodRequests;
  33 import com.sun.javafx.scene.traversal.Algorithm;
  34 import com.sun.javafx.scene.traversal.Direction;
  35 import com.sun.javafx.scene.traversal.ParentTraversalEngine;
  36 import com.sun.javafx.scene.traversal.TraversalContext;
  37 import javafx.beans.InvalidationListener;
  38 import javafx.beans.value.ObservableValue;
  39 import javafx.css.Styleable;
  40 import javafx.event.EventHandler;
  41 import javafx.geometry.Bounds;
  42 import javafx.geometry.HPos;
  43 import javafx.geometry.Point2D;
  44 import javafx.geometry.VPos;
  45 import javafx.scene.AccessibleAttribute;
  46 import javafx.scene.Node;
  47 import javafx.scene.control.ComboBoxBase;
  48 import javafx.scene.control.PopupControl;
  49 import javafx.scene.control.Skin;
  50 import javafx.scene.control.Skinnable;
  51 import javafx.scene.control.TextField;
  52 import javafx.scene.input.DragEvent;
  53 import javafx.scene.input.KeyCode;
  54 import javafx.scene.input.KeyEvent;
  55 import javafx.scene.input.MouseEvent;
  56 import javafx.scene.layout.Region;
  57 import javafx.stage.WindowEvent;
  58 import javafx.util.StringConverter;
  59 
  60 /**
  61  * An abstract class that extends the functionality of {@link ComboBoxBaseSkin}
  62  * to include API related to showing ComboBox-like controls as popups.
  63  *
  64  * @param <T> The type of the ComboBox-like control.
  65  * @since 9
  66  */
  67 public abstract class ComboBoxPopupControl<T> extends ComboBoxBaseSkin<T> {
  68 
  69     /***************************************************************************
  70      *                                                                         *
  71      * Private fields                                                          *
  72      *                                                                         *
  73      **************************************************************************/
  74 
  75     PopupControl popup;
  76 
  77     private boolean popupNeedsReconfiguring = true;
  78 
  79     private final ComboBoxBase<T> comboBoxBase;
  80     private TextField textField;
  81 
  82     private String initialTextFieldValue = null;
  83 
  84 
  85 
  86     /***************************************************************************
  87      *                                                                         *
  88      * TextField Listeners                                                     *
  89      *                                                                         *
  90      **************************************************************************/
  91 
  92     private EventHandler<MouseEvent> textFieldMouseEventHandler = event -> {
  93         ComboBoxBase<T> comboBoxBase = getSkinnable();
  94         if (!event.getTarget().equals(comboBoxBase)) {
  95             comboBoxBase.fireEvent(event.copyFor(comboBoxBase, comboBoxBase));
  96             event.consume();
  97         }
  98     };
  99     private EventHandler<DragEvent> textFieldDragEventHandler = event -> {
 100         ComboBoxBase<T> comboBoxBase = getSkinnable();
 101         if (!event.getTarget().equals(comboBoxBase)) {
 102             comboBoxBase.fireEvent(event.copyFor(comboBoxBase, comboBoxBase));
 103             event.consume();
 104         }
 105     };
 106 
 107 
 108 
 109     /***************************************************************************
 110      *                                                                         *
 111      * Constructors                                                            *
 112      *                                                                         *
 113      **************************************************************************/
 114 
 115     /**
 116      * Creates a new instance of ComboBoxPopupControl, although note that this
 117      * instance does not handle any behavior / input mappings - this needs to be
 118      * handled appropriately by subclasses.
 119      *
 120      * @param control The control that this skin should be installed onto.
 121      */
 122     public ComboBoxPopupControl(ComboBoxBase<T> control) {
 123         super(control);
 124         this.comboBoxBase = control;
 125 
 126         // editable input node
 127         this.textField = getEditor() != null ? getEditableInputNode() : null;
 128 
 129         // Fix for RT-29565. Without this the textField does not have a correct
 130         // pref width at startup, as it is not part of the scenegraph (and therefore
 131         // has no pref width until after the first measurements have been taken).
 132         if (this.textField != null) {
 133             getChildren().add(textField);
 134         }
 135 
 136         // move fake focus in to the textfield if the comboBox is editable
 137         comboBoxBase.focusedProperty().addListener((ov, t, hasFocus) -> {
 138             if (getEditor() != null) {
 139                 // Fix for the regression noted in a comment in RT-29885.
 140                 ((FakeFocusTextField)textField).setFakeFocus(hasFocus);
 141             }
 142         });
 143 
 144         comboBoxBase.addEventFilter(KeyEvent.ANY, ke -> {
 145             if (textField == null || getEditor() == null) {
 146                 handleKeyEvent(ke, false);
 147             } else {
 148                 // This prevents a stack overflow from our rebroadcasting of the
 149                 // event to the textfield that occurs in the final else statement
 150                 // of the conditions below.
 151                 if (ke.getTarget().equals(textField)) return;
 152 
 153                 switch (ke.getCode()) {
 154                   case ESCAPE:
 155                   case F10:
 156                       // Allow to bubble up.
 157                       break;
 158 
 159                   case ENTER:
 160                     handleKeyEvent(ke, true);
 161                     break;
 162 
 163                   default:
 164                     // Fix for the regression noted in a comment in RT-29885.
 165                     // This forwards the event down into the TextField when
 166                     // the key event is actually received by the ComboBox.
 167                     textField.fireEvent(ke.copyFor(textField, textField));
 168                     ke.consume();
 169                 }
 170             }
 171         });
 172 
 173         // RT-38978: Forward input method events to TextField if editable.
 174         if (comboBoxBase.getOnInputMethodTextChanged() == null) {
 175             comboBoxBase.setOnInputMethodTextChanged(event -> {
 176                 if (textField != null && getEditor() != null && comboBoxBase.getScene().getFocusOwner() == comboBoxBase) {
 177                     if (textField.getOnInputMethodTextChanged() != null) {
 178                         textField.getOnInputMethodTextChanged().handle(event);
 179                     }
 180                 }
 181             });
 182         }
 183 
 184         // Fix for RT-36902, where focus traversal was getting stuck inside the ComboBox
 185         ParentHelper.setTraversalEngine(comboBoxBase,
 186                 new ParentTraversalEngine(comboBoxBase, new Algorithm() {
 187 
 188             @Override public Node select(Node owner, Direction dir, TraversalContext context) {
 189                 return null;
 190             }
 191 
 192             @Override public Node selectFirst(TraversalContext context) {
 193                 return null;
 194             }
 195 
 196             @Override public Node selectLast(TraversalContext context) {
 197                 return null;
 198             }
 199         }));
 200 
 201         updateEditable();
 202     }
 203 
 204 
 205 
 206     /***************************************************************************
 207      *                                                                         *
 208      * Public API                                                              *
 209      *                                                                         *
 210      **************************************************************************/
 211 
 212     /**
 213      * This method should return the Node that will be displayed when the user
 214      * clicks on the ComboBox 'button' area.
 215      * @return the Node that will be displayed when the user clicks on the
 216      * ComboBox 'button' area
 217      */
 218     protected abstract Node getPopupContent();
 219 
 220     /**
 221      * Subclasses are responsible for getting the editor. This will be removed
 222      * in FX 9 when the editor property is moved up to ComboBoxBase with
 223      * JDK-8130354
 224      *
 225      * Note: ComboBoxListViewSkin should return null if editable is false, even
 226      * if the ComboBox does have an editor set.
 227      * @return the editor
 228      */
 229     protected abstract TextField getEditor();
 230 
 231     /**
 232      * Subclasses are responsible for getting the converter. This will be
 233      * removed in FX 9 when the converter property is moved up to ComboBoxBase
 234      * with JDK-8130354.
 235      * @return the string converter
 236      */
 237     protected abstract StringConverter<T> getConverter();
 238 
 239     /** {@inheritDoc} */
 240     @Override public void show() {
 241         if (getSkinnable() == null) {
 242             throw new IllegalStateException("ComboBox is null");
 243         }
 244 
 245         Node content = getPopupContent();
 246         if (content == null) {
 247             throw new IllegalStateException("Popup node is null");
 248         }
 249 
 250         if (getPopup().isShowing()) return;
 251 
 252         positionAndShowPopup();
 253     }
 254 
 255     /** {@inheritDoc} */
 256     @Override public void hide() {
 257         if (popup != null && popup.isShowing()) {
 258             popup.hide();
 259         }
 260     }
 261 
 262 
 263 
 264     /***************************************************************************
 265      *                                                                         *
 266      * Private implementation                                                  *
 267      *                                                                         *
 268      **************************************************************************/
 269 
 270     PopupControl getPopup() {
 271         if (popup == null) {
 272             createPopup();
 273         }
 274         return popup;
 275     }
 276 
 277     TextField getEditableInputNode() {
 278         if (textField == null && getEditor() != null) {
 279             textField = getEditor();
 280             textField.setFocusTraversable(false);
 281             textField.promptTextProperty().bind(comboBoxBase.promptTextProperty());
 282             textField.tooltipProperty().bind(comboBoxBase.tooltipProperty());
 283 
 284             // Fix for JDK-8145515 - in short the ComboBox was firing the event down to
 285             // the TextField, and then the TextField was firing it back up to the
 286             // ComboBox, resulting in stack overflows.
 287             textField.getProperties().put(TextInputControlBehavior.DISABLE_FORWARD_TO_PARENT, true);
 288 
 289             // Fix for RT-21406: ComboBox do not show initial text value
 290             initialTextFieldValue = textField.getText();
 291             // End of fix (see updateDisplayNode below for the related code)
 292         }
 293 
 294         return textField;
 295     }
 296 
 297     void setTextFromTextFieldIntoComboBoxValue() {
 298         if (getEditor() != null) {
 299             StringConverter<T> c = getConverter();
 300             if (c != null) {
 301                 T oldValue = comboBoxBase.getValue();
 302                 T value = oldValue;
 303                 String text = textField.getText();
 304 
 305                 // conditional check here added due to RT-28245
 306                 if (oldValue == null && (text == null || text.isEmpty())) {
 307                     value = null;
 308                 } else {
 309                     try {
 310                         value = c.fromString(text);
 311                     } catch (Exception ex) {
 312                         // Most likely a parsing error, such as DateTimeParseException
 313                     }
 314                 }
 315 
 316                 if ((value != null || oldValue != null) && (value == null || !value.equals(oldValue))) {
 317                     // no point updating values needlessly if they are the same
 318                     comboBoxBase.setValue(value);
 319                 }
 320 
 321                 updateDisplayNode();
 322             }
 323         }
 324     }
 325 
 326     void updateDisplayNode() {
 327         if (textField != null && getEditor() != null) {
 328             T value = comboBoxBase.getValue();
 329             StringConverter<T> c = getConverter();
 330 
 331             if (initialTextFieldValue != null && ! initialTextFieldValue.isEmpty()) {
 332                 // Remainder of fix for RT-21406: ComboBox do not show initial text value
 333                 textField.setText(initialTextFieldValue);
 334                 initialTextFieldValue = null;
 335                 // end of fix
 336             } else {
 337                 String stringValue = c.toString(value);
 338                 if (value == null || stringValue == null) {
 339                     textField.setText("");
 340                 } else if (! stringValue.equals(textField.getText())) {
 341                     textField.setText(stringValue);
 342                 }
 343             }
 344         }
 345     }
 346 
 347     void updateEditable() {
 348         TextField newTextField = getEditor();
 349 
 350         if (getEditor() == null) {
 351             // remove event filters
 352             if (textField != null) {
 353                 textField.removeEventFilter(MouseEvent.DRAG_DETECTED, textFieldMouseEventHandler);
 354                 textField.removeEventFilter(DragEvent.ANY, textFieldDragEventHandler);
 355 
 356                 comboBoxBase.setInputMethodRequests(null);
 357             }
 358         } else if (newTextField != null) {
 359             // add event filters
 360 
 361             // Fix for RT-31093 - drag events from the textfield were not surfacing
 362             // properly for the ComboBox.
 363             newTextField.addEventFilter(MouseEvent.DRAG_DETECTED, textFieldMouseEventHandler);
 364             newTextField.addEventFilter(DragEvent.ANY, textFieldDragEventHandler);
 365 
 366             // RT-38978: Forward input method requests to TextField.
 367             comboBoxBase.setInputMethodRequests(new ExtendedInputMethodRequests() {
 368                 @Override public Point2D getTextLocation(int offset) {
 369                     return newTextField.getInputMethodRequests().getTextLocation(offset);
 370                 }
 371 
 372                 @Override public int getLocationOffset(int x, int y) {
 373                     return newTextField.getInputMethodRequests().getLocationOffset(x, y);
 374                 }
 375 
 376                 @Override public void cancelLatestCommittedText() {
 377                     newTextField.getInputMethodRequests().cancelLatestCommittedText();
 378                 }
 379 
 380                 @Override public String getSelectedText() {
 381                     return newTextField.getInputMethodRequests().getSelectedText();
 382                 }
 383 
 384                 @Override public int getInsertPositionOffset() {
 385                     return ((ExtendedInputMethodRequests)newTextField.getInputMethodRequests()).getInsertPositionOffset();
 386                 }
 387 
 388                 @Override public String getCommittedText(int begin, int end) {
 389                     return ((ExtendedInputMethodRequests)newTextField.getInputMethodRequests()).getCommittedText(begin, end);
 390                 }
 391 
 392                 @Override public int getCommittedTextLength() {
 393                     return ((ExtendedInputMethodRequests)newTextField.getInputMethodRequests()).getCommittedTextLength();
 394                 }
 395             });
 396         }
 397 
 398         textField = newTextField;
 399     }
 400 
 401     private Point2D getPrefPopupPosition() {
 402         return com.sun.javafx.util.Utils.pointRelativeTo(getSkinnable(), getPopupContent(), HPos.CENTER, VPos.BOTTOM, 0, 0, true);
 403     }
 404 
 405     private void positionAndShowPopup() {
 406         final PopupControl _popup = getPopup();
 407         _popup.getScene().setNodeOrientation(getSkinnable().getEffectiveNodeOrientation());
 408 
 409 
 410         final Node popupContent = getPopupContent();
 411         sizePopup();
 412 
 413         Point2D p = getPrefPopupPosition();
 414 
 415         popupNeedsReconfiguring = true;
 416         reconfigurePopup();
 417 
 418         final ComboBoxBase<T> comboBoxBase = getSkinnable();
 419         _popup.show(comboBoxBase.getScene().getWindow(),
 420                 snapPositionX(p.getX()),
 421                 snapPositionY(p.getY()));
 422 
 423         popupContent.requestFocus();
 424 
 425         // second call to sizePopup here to enable proper sizing _after_ the popup
 426         // has been displayed. See RT-37622 for more detail.
 427         sizePopup();
 428     }
 429 
 430     private void sizePopup() {
 431         final Node popupContent = getPopupContent();
 432 
 433         if (popupContent instanceof Region) {
 434             // snap to pixel
 435             final Region r = (Region) popupContent;
 436 
 437             // 0 is used here for the width due to RT-46097
 438             double prefHeight = snapSizeY(r.prefHeight(0));
 439             double minHeight = snapSizeY(r.minHeight(0));
 440             double maxHeight = snapSizeY(r.maxHeight(0));
 441             double h = snapSizeY(Math.min(Math.max(prefHeight, minHeight), Math.max(minHeight, maxHeight)));
 442 
 443             double prefWidth = snapSizeX(r.prefWidth(h));
 444             double minWidth = snapSizeX(r.minWidth(h));
 445             double maxWidth = snapSizeX(r.maxWidth(h));
 446             double w = snapSizeX(Math.min(Math.max(prefWidth, minWidth), Math.max(minWidth, maxWidth)));
 447 
 448             popupContent.resize(w, h);
 449         } else {
 450             popupContent.autosize();
 451         }
 452     }
 453 
 454     private void createPopup() {
 455         popup = new PopupControl() {
 456             @Override public Styleable getStyleableParent() {
 457                 return ComboBoxPopupControl.this.getSkinnable();
 458             }
 459             {
 460                 setSkin(new Skin<Skinnable>() {
 461                     @Override public Skinnable getSkinnable() { return ComboBoxPopupControl.this.getSkinnable(); }
 462                     @Override public Node getNode() { return getPopupContent(); }
 463                     @Override public void dispose() { }
 464                 });
 465             }
 466         };
 467         popup.getStyleClass().add(Properties.COMBO_BOX_STYLE_CLASS);
 468         popup.setConsumeAutoHidingEvents(false);
 469         popup.setAutoHide(true);
 470         popup.setAutoFix(true);
 471         popup.setHideOnEscape(true);
 472         popup.setOnAutoHide(e -> getBehavior().onAutoHide(popup));
 473         popup.addEventHandler(MouseEvent.MOUSE_CLICKED, t -> {
 474             // RT-18529: We listen to mouse input that is received by the popup
 475             // but that is not consumed, and assume that this is due to the mouse
 476             // clicking outside of the node, but in areas such as the
 477             // dropshadow.
 478             getBehavior().onAutoHide(popup);
 479         });
 480         popup.addEventHandler(WindowEvent.WINDOW_HIDDEN, t -> {
 481             // Make sure the accessibility focus returns to the combo box
 482             // after the window closes.
 483             getSkinnable().notifyAccessibleAttributeChanged(AccessibleAttribute.FOCUS_NODE);
 484         });
 485 
 486         // Fix for RT-21207
 487         InvalidationListener layoutPosListener = o -> {
 488             popupNeedsReconfiguring = true;
 489             reconfigurePopup();
 490         };
 491         getSkinnable().layoutXProperty().addListener(layoutPosListener);
 492         getSkinnable().layoutYProperty().addListener(layoutPosListener);
 493         getSkinnable().widthProperty().addListener(layoutPosListener);
 494         getSkinnable().heightProperty().addListener(layoutPosListener);
 495 
 496         // RT-36966 - if skinnable's scene becomes null, ensure popup is closed
 497         getSkinnable().sceneProperty().addListener(o -> {
 498             if (((ObservableValue)o).getValue() == null) {
 499                 hide();
 500             }
 501         });
 502 
 503     }
 504 
 505     void reconfigurePopup() {
 506         // RT-26861. Don't call getPopup() here because it may cause the popup
 507         // to be created too early, which leads to memory leaks like those noted
 508         // in RT-32827.
 509         if (popup == null) return;
 510 
 511         final boolean isShowing = popup.isShowing();
 512         if (! isShowing) return;
 513 
 514         if (! popupNeedsReconfiguring) return;
 515         popupNeedsReconfiguring = false;
 516 
 517         final Point2D p = getPrefPopupPosition();
 518 
 519         final Node popupContent = getPopupContent();
 520         final double minWidth = popupContent.prefWidth(Region.USE_COMPUTED_SIZE);
 521         final double minHeight = popupContent.prefHeight(Region.USE_COMPUTED_SIZE);
 522 
 523         if (p.getX() > -1) popup.setAnchorX(p.getX());
 524         if (p.getY() > -1) popup.setAnchorY(p.getY());
 525         if (minWidth > -1) popup.setMinWidth(minWidth);
 526         if (minHeight > -1) popup.setMinHeight(minHeight);
 527 
 528         final Bounds b = popupContent.getLayoutBounds();
 529         final double currentWidth = b.getWidth();
 530         final double currentHeight = b.getHeight();
 531         final double newWidth  = currentWidth < minWidth ? minWidth : currentWidth;
 532         final double newHeight = currentHeight < minHeight ? minHeight : currentHeight;
 533 
 534         if (newWidth != currentWidth || newHeight != currentHeight) {
 535             // Resizing content to resolve issues such as RT-32582 and RT-33700
 536             // (where RT-33700 was introduced due to a previous fix for RT-32582)
 537             popupContent.resize(newWidth, newHeight);
 538             if (popupContent instanceof Region) {
 539                 ((Region)popupContent).setMinSize(newWidth, newHeight);
 540                 ((Region)popupContent).setPrefSize(newWidth, newHeight);
 541             }
 542         }
 543     }
 544 
 545     private void handleKeyEvent(KeyEvent ke, boolean doConsume) {
 546         // When the user hits the enter or F4 keys, we respond before
 547         // ever giving the event to the TextField.
 548         if (ke.getCode() == KeyCode.ENTER) {
 549             if (ke.isConsumed() || ke.getEventType() != KeyEvent.KEY_RELEASED) {
 550                 return;
 551             }
 552             setTextFromTextFieldIntoComboBoxValue();
 553 
 554             if (doConsume && comboBoxBase.getOnAction() != null) {
 555                 ke.consume();
 556             } else if (textField != null) {
 557                 textField.fireEvent(ke);
 558             }
 559         } else if (ke.getCode() == KeyCode.F4) {
 560             if (ke.getEventType() == KeyEvent.KEY_RELEASED) {
 561                 if (comboBoxBase.isShowing()) comboBoxBase.hide();
 562                 else comboBoxBase.show();
 563             }
 564             ke.consume(); // we always do a consume here (otherwise unit tests fail)
 565         } else if (ke.getCode() == KeyCode.F10 || ke.getCode() == KeyCode.ESCAPE) {
 566             // RT-23275: The TextField fires F10 and ESCAPE key events
 567             // up to the parent, which are then fired back at the
 568             // TextField, and this ends up in an infinite loop until
 569             // the stack overflows. So, here we consume these two
 570             // events and stop them from going any further.
 571             if (doConsume) ke.consume();
 572         }
 573     }
 574 
 575 
 576 
 577     /***************************************************************************
 578      *                                                                         *
 579      * Support classes                                                         *
 580      *                                                                         *
 581      **************************************************************************/
 582 
 583 
 584 
 585 
 586 
 587     /***************************************************************************
 588      *                                                                         *
 589      * Stylesheet Handling                                                     *
 590      *                                                                         *
 591      **************************************************************************/
 592 
 593 }