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