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             final double prefWidth = r.prefWidth(-1);
 212             final double minWidth = r.minWidth(-1);
 213             final double maxWidth = r.maxWidth(-1);
 214             final double w = snapSize(Math.min(Math.max(prefWidth, minWidth), Math.max(minWidth, maxWidth)));
 215 
 216             final double prefHeight = r.prefHeight(w);
 217             final double minHeight = r.minHeight(w);
 218             final double maxHeight = r.maxHeight(w);
 219             final double h = snapSize(Math.min(Math.max(prefHeight, minHeight), Math.max(minHeight, maxHeight)));
 220 
 221             popupContent.resize(w, h);
 222         } else {
 223             popupContent.autosize();
 224         }
 225     }
 226     
 227     private void createPopup() {
 228         popup = new PopupControl() {
 229 
 230             @Override public Styleable getStyleableParent() {
 231                 return ComboBoxPopupControl.this.getSkinnable();
 232             }
 233             {
 234                 setSkin(new Skin<Skinnable>() {
 235                     @Override public Skinnable getSkinnable() { return ComboBoxPopupControl.this.getSkinnable(); }
 236                     @Override public Node getNode() { return getPopupContent(); }
 237                     @Override public void dispose() { }
 238                 });
 239             }
 240 
 241         };
 242         popup.getStyleClass().add(COMBO_BOX_STYLE_CLASS);
 243         popup.setConsumeAutoHidingEvents(false);
 244         popup.setAutoHide(true);
 245         popup.setAutoFix(true);
 246         popup.setHideOnEscape(true);
 247         popup.setOnAutoHide(e -> {
 248             getBehavior().onAutoHide();
 249         });
 250         popup.addEventHandler(MouseEvent.MOUSE_CLICKED, t -> {
 251             // RT-18529: We listen to mouse input that is received by the popup
 252             // but that is not consumed, and assume that this is due to the mouse
 253             // clicking outside of the node, but in areas such as the
 254             // dropshadow.
 255             getBehavior().onAutoHide();
 256         });
 257         popup.addEventHandler(WindowEvent.WINDOW_HIDDEN, t -> {
 258             // Make sure the accessibility focus returns to the combo box
 259             // after the window closes.
 260             getSkinnable().notifyAccessibleAttributeChanged(AccessibleAttribute.FOCUS_NODE);
 261         });
 262         
 263         // Fix for RT-21207
 264         InvalidationListener layoutPosListener = o -> {
 265             popupNeedsReconfiguring = true;
 266             reconfigurePopup();
 267         };
 268         getSkinnable().layoutXProperty().addListener(layoutPosListener);
 269         getSkinnable().layoutYProperty().addListener(layoutPosListener);
 270         getSkinnable().widthProperty().addListener(layoutPosListener);
 271         getSkinnable().heightProperty().addListener(layoutPosListener);
 272 
 273         // RT-36966 - if skinnable's scene becomes null, ensure popup is closed
 274         getSkinnable().sceneProperty().addListener(o -> {
 275             if (((ObservableValue)o).getValue() == null) {
 276                 hide();
 277             }
 278         });
 279 
 280     }
 281 
 282     void reconfigurePopup() {
 283         // RT-26861. Don't call getPopup() here because it may cause the popup
 284         // to be created too early, which leads to memory leaks like those noted
 285         // in RT-32827.
 286         if (popup == null) return;
 287 
 288         final boolean isShowing = popup.isShowing();
 289         if (! isShowing) return;
 290 
 291         if (! popupNeedsReconfiguring) return;
 292         popupNeedsReconfiguring = false;
 293 
 294         final Point2D p = getPrefPopupPosition();
 295 
 296         final Node popupContent = getPopupContent();
 297         final double minWidth = popupContent.prefWidth(Region.USE_COMPUTED_SIZE);
 298         final double minHeight = popupContent.prefHeight(Region.USE_COMPUTED_SIZE);
 299 
 300         if (p.getX() > -1) popup.setAnchorX(p.getX());
 301         if (p.getY() > -1) popup.setAnchorY(p.getY());
 302         if (minWidth > -1) popup.setMinWidth(minWidth);
 303         if (minHeight > -1) popup.setMinHeight(minHeight);
 304 
 305         final Bounds b = popupContent.getLayoutBounds();
 306         final double currentWidth = b.getWidth();
 307         final double currentHeight = b.getHeight();
 308         final double newWidth  = currentWidth < minWidth ? minWidth : currentWidth;
 309         final double newHeight = currentHeight < minHeight ? minHeight : currentHeight;
 310 
 311         if (newWidth != currentWidth || newHeight != currentHeight) {
 312             // Resizing content to resolve issues such as RT-32582 and RT-33700
 313             // (where RT-33700 was introduced due to a previous fix for RT-32582)
 314             popupContent.resize(newWidth, newHeight);
 315             if (popupContent instanceof Region) {
 316                 ((Region)popupContent).setMinSize(newWidth, newHeight);
 317                 ((Region)popupContent).setPrefSize(newWidth, newHeight);
 318             }
 319         }
 320     }
 321 
 322 
 323 
 324 
 325 
 326     /***************************************************************************
 327      *                                                                         *
 328      * TextField Listeners                                                     *
 329      *                                                                         *
 330      **************************************************************************/
 331     
 332     private EventHandler<MouseEvent> textFieldMouseEventHandler = 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     private EventHandler<DragEvent> textFieldDragEventHandler = event -> {
 340         ComboBoxBase<T> comboBoxBase = getSkinnable();
 341         if (!event.getTarget().equals(comboBoxBase)) {
 342             comboBoxBase.fireEvent(event.copyFor(comboBoxBase, comboBoxBase));
 343             event.consume();
 344         }
 345     };
 346 
 347 
 348     /**
 349      * Subclasses are responsible for getting the editor. This will be removed
 350      * in FX 9 when the editor property is moved up to ComboBoxBase.
 351      *
 352      * Note: ComboBoxListViewSkin should return null if editable is false, even
 353      * if the ComboBox does have an editor set.
 354      */
 355     protected abstract TextField getEditor();
 356 
 357     /**
 358      * Subclasses are responsible for getting the converter. This will be
 359      * removed in FX 9 when the converter property is moved up to ComboBoxBase.
 360      */
 361     protected abstract StringConverter<T> getConverter();
 362 
 363     private String initialTextFieldValue = null;
 364     protected TextField getEditableInputNode() {
 365         if (textField == null && getEditor() != null) {
 366             textField = getEditor();
 367             textField.setFocusTraversable(false);
 368             textField.promptTextProperty().bind(comboBoxBase.promptTextProperty());
 369             textField.tooltipProperty().bind(comboBoxBase.tooltipProperty());
 370 
 371             // Fix for RT-21406: ComboBox do not show initial text value
 372             initialTextFieldValue = textField.getText();
 373             // End of fix (see updateDisplayNode below for the related code)
 374         }
 375 
 376         return textField;
 377     }
 378 
 379     protected void setTextFromTextFieldIntoComboBoxValue() {
 380         if (getEditor() != null) {
 381             StringConverter<T> c = getConverter();
 382             if (c != null) {
 383                 T oldValue = comboBoxBase.getValue();
 384                 T value = oldValue;
 385                 String text = textField.getText();
 386 
 387                 // conditional check here added due to RT-28245
 388                 if (oldValue == null && (text == null || text.isEmpty())) {
 389                     value = null;
 390                 } else {
 391                     try {
 392                         value = c.fromString(text);
 393                     } catch (Exception ex) {
 394                         // Most likely a parsing error, such as DateTimeParseException
 395                     }
 396                 }
 397 
 398                 if ((value != null || oldValue != null) && (value == null || !value.equals(oldValue))) {
 399                     // no point updating values needlessly if they are the same
 400                     comboBoxBase.setValue(value);
 401                 }
 402 
 403                 updateDisplayNode();
 404             }
 405         }
 406     }
 407 
 408     protected void updateDisplayNode() {
 409         if (textField != null && getEditor() != null) {
 410             T value = comboBoxBase.getValue();
 411             StringConverter<T> c = getConverter();
 412 
 413             if (initialTextFieldValue != null && ! initialTextFieldValue.isEmpty()) {
 414                 // Remainder of fix for RT-21406: ComboBox do not show initial text value
 415                 textField.setText(initialTextFieldValue);
 416                 initialTextFieldValue = null;
 417                 // end of fix
 418             } else {
 419                 String stringValue = c.toString(value);
 420                 if (value == null || stringValue == null) {
 421                     textField.setText("");
 422                 } else if (! stringValue.equals(textField.getText())) {
 423                     textField.setText(stringValue);
 424                 }
 425             }
 426         }
 427     }
 428 
 429 
 430     private void handleKeyEvent(KeyEvent ke, boolean doConsume) {
 431         // When the user hits the enter or F4 keys, we respond before
 432         // ever giving the event to the TextField.
 433         if (ke.getCode() == KeyCode.ENTER) {
 434             setTextFromTextFieldIntoComboBoxValue();
 435 
 436             if (doConsume && comboBoxBase.getOnAction() != null) {
 437                 ke.consume();
 438             } else {
 439                 forwardToParent(ke);
 440             }
 441         } else if (ke.getCode() == KeyCode.F4) {
 442             if (ke.getEventType() == KeyEvent.KEY_RELEASED) {
 443                 if (comboBoxBase.isShowing()) comboBoxBase.hide();
 444                 else comboBoxBase.show();
 445             }
 446             ke.consume(); // we always do a consume here (otherwise unit tests fail)
 447         }
 448     }
 449 
 450     private void forwardToParent(KeyEvent event) {
 451         if (comboBoxBase.getParent() != null) {
 452             comboBoxBase.getParent().fireEvent(event);
 453         }
 454     }
 455 
 456     protected void updateEditable() {
 457         TextField newTextField = getEditor();
 458 
 459         if (getEditor() == null) {
 460             // remove event filters
 461             if (textField != null) {
 462                 textField.removeEventFilter(MouseEvent.DRAG_DETECTED, textFieldMouseEventHandler);
 463                 textField.removeEventFilter(DragEvent.ANY, textFieldDragEventHandler);
 464 
 465                 comboBoxBase.setInputMethodRequests(null);
 466             }
 467         } else if (newTextField != null) {
 468             // add event filters
 469 
 470             // Fix for RT-31093 - drag events from the textfield were not surfacing
 471             // properly for the ComboBox.
 472             newTextField.addEventFilter(MouseEvent.DRAG_DETECTED, textFieldMouseEventHandler);
 473             newTextField.addEventFilter(DragEvent.ANY, textFieldDragEventHandler);
 474 
 475             // RT-38978: Forward input method requests to TextField.
 476             comboBoxBase.setInputMethodRequests(new ExtendedInputMethodRequests() {
 477                 @Override public Point2D getTextLocation(int offset) {
 478                     return newTextField.getInputMethodRequests().getTextLocation(offset);
 479                 }
 480 
 481                 @Override public int getLocationOffset(int x, int y) {
 482                     return newTextField.getInputMethodRequests().getLocationOffset(x, y);
 483                 }
 484 
 485                 @Override public void cancelLatestCommittedText() {
 486                     newTextField.getInputMethodRequests().cancelLatestCommittedText();
 487                 }
 488 
 489                 @Override public String getSelectedText() {
 490                     return newTextField.getInputMethodRequests().getSelectedText();
 491                 }
 492 
 493                 @Override public int getInsertPositionOffset() {
 494                     return ((ExtendedInputMethodRequests)newTextField.getInputMethodRequests()).getInsertPositionOffset();
 495                 }
 496 
 497                 @Override public String getCommittedText(int begin, int end) {
 498                     return ((ExtendedInputMethodRequests)newTextField.getInputMethodRequests()).getCommittedText(begin, end);
 499                 }
 500 
 501                 @Override public int getCommittedTextLength() {
 502                     return ((ExtendedInputMethodRequests)newTextField.getInputMethodRequests()).getCommittedTextLength();
 503                 }
 504             });
 505         }
 506 
 507         textField = newTextField;
 508     }
 509 
 510     /***************************************************************************
 511      *                                                                         *
 512      * Support classes                                                         *
 513      *                                                                         *
 514      **************************************************************************/
 515 
 516     public static final class FakeFocusTextField extends TextField {
 517 
 518         @Override public void requestFocus() {
 519             if (getParent() != null) {
 520                 getParent().requestFocus();
 521             }
 522         }
 523 
 524         public void setFakeFocus(boolean b) {
 525             setFocused(b);
 526         }
 527 
 528         @Override
 529         public Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) {
 530             switch (attribute) {
 531                 case FOCUS_ITEM: 
 532                     /* Internally comboBox reassign its focus the text field.
 533                      * For the accessibility perspective it is more meaningful
 534                      * if the focus stays with the comboBox control.
 535                      */
 536                     return getParent();
 537                 default: return super.queryAccessibleAttribute(attribute, parameters);
 538             }
 539         }
 540     }
 541 
 542 
 543 
 544     /***************************************************************************
 545      *                                                                         *
 546      * Stylesheet Handling                                                     *
 547      *                                                                         *
 548      **************************************************************************/
 549 
 550     private static PseudoClass CONTAINS_FOCUS_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("contains-focus");
 551 
 552 }