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