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