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